853 Commits

Author SHA1 Message Date
Pierre Tachoire
0045924973 wip: force wpt ci 2025-05-08 14:33:02 +02:00
Pierre Tachoire
d605ea1b8b update wpt tests
Add many more tests from WPT
2025-05-08 14:27:17 +02:00
Karl Seguin
17ed502123 Merge pull request #601 from lightpanda-io/http_gzip
Support gzip compressed content for the synchronous http client
2025-05-08 13:52:05 +08:00
Karl Seguin
3466325d4d Merge pull request #610 from lightpanda-io/loop_interval_cleanup
Optimize intervals, and make sure they're probably cleaned up.
2025-05-08 07:44:59 +08:00
muki
1613345dec Merge pull request #611 from lightpanda-io/nix
Add Nix Development Support
2025-05-07 06:19:22 -07:00
Muki Kiboigo
759accef07 add README entry about Nix 2025-05-07 06:16:32 -07:00
Muki Kiboigo
6d02669fc3 add flake.nix 2025-05-07 06:16:32 -07:00
Karl Seguin
6d8d688063 Optimize intervals, and make sure they're probably cleaned up.
A loop interval will no longer stop the loop from returning from `run`, and
no longer requires mutating event_nb on each iteration.

Re-enable microtask loop, which I accidentally stopped in a previous commit.
2025-05-07 19:20:26 +08:00
Karl Seguin
5207bdfd85 Merge pull request #608 from lightpanda-io/wpt-opts-url
wpt: use local url for wpt tests
2025-05-07 16:07:24 +08:00
Karl Seguin
690d4238e8 Merge pull request #607 from lightpanda-io/fix_int_overflow
fix overflow when setting timeout/interval
2025-05-07 16:05:37 +08:00
Pierre Tachoire
95ee78b1bd wpt: use local url for wpt tests 2025-05-07 09:43:18 +02:00
Karl Seguin
25eadc2263 fix overflow when setting timeout/interval 2025-05-07 15:37:47 +08:00
Pierre Tachoire
28e4065890 Merge pull request #606 from lightpanda-io/no_owned_slice
remove unecessary toOwnedSlice
2025-05-07 08:47:37 +02:00
Karl Seguin
e44388b506 remove unecessary toOwnedSlice 2025-05-07 14:40:03 +08:00
Pierre Tachoire
540dea9fc3 Merge pull request #604 from lightpanda-io/non_generic_named_function
Change NamedFunction from a generic to a normal struct.
2025-05-07 08:32:14 +02:00
Karl Seguin
c31290b794 Change NamedFunction from a generic to a normal struct.
NamedFunction is important for displaying good error messages when there's
something wrong with the Zig structs we're trying to bind to JS. By making it
a normal struct, it's easier and cheaper to pass wherever an @compileError
might be needed.
2025-05-07 13:50:25 +08:00
Pierre Tachoire
f1fe4c0c70 Merge pull request #600 from lightpanda-io/timeouts_and_intervals
Make intervals easier and faster, add window.setInterval and clearInt…
2025-05-06 15:18:15 +02:00
Pierre Tachoire
921ac18876 Merge pull request #602 from lightpanda-io/Subpixel-mouse-events
Subpixel mouse events
2025-05-06 15:11:40 +02:00
sjorsdonkers
505ad0380e typo 2025-05-06 12:52:08 +02:00
sjorsdonkers
2b7a7c0054 floor the pixels 2025-05-06 12:45:18 +02:00
sjorsdonkers
0dea4c51b7 Subpixel mouse events 2025-05-06 12:45:17 +02:00
Karl Seguin
3095f2110e Merge pull request #599 from lightpanda-io/NativeIntersectionObserver
Native IntersectionObserver
2025-05-06 17:36:56 +08:00
sjorsdonkers
e32d35b156 no reobserve rootbounds for Window 2025-05-06 11:28:08 +02:00
sjorsdonkers
db28336e5d Support options in observer and tests 2025-05-06 11:28:07 +02:00
sjorsdonkers
c5c5accaa8 Native IntersectionObserver 2025-05-06 11:28:06 +02:00
Karl Seguin
78bfdd4515 Support gzip compressed content for the synchronous http client 2025-05-06 16:23:44 +08:00
Karl Seguin
01aa826a24 Make intervals easier and faster, add window.setInterval and clearInterval
When the browser microtask was added, zig-specific timeout functions were
added to the loop. This was necessary for two reasons:
1 - The existing functions were JS specific
2 - We wanted a different reset counter for JS and Zig

Like we did in https://github.com/lightpanda-io/browser/pull/577, the loop is
now JS-agnostic. It gets a Zig callback, and the Zig callback can execute JS
(or do whatever). An intrusive node, like with events, is used to minimize
allocations.

Also, because the microtask was recently moved to the page, there is no longer
a need for separate event counters. All timeouts are scoped to the page.

The new timeout callback can now be used to efficiently reschedule a task. This
reuses the IO.completion and Context, avoiding 2 allocations. More importantly
it makes the internal timer_id static for the lifetime of an "interval". This
is important for window.setInterval, where the callback can itself clear the
interval, which we would need to detect in the callback handler to avoid
re-scheduling. With the stable timer_id, the existing cancel mechanism works
as expected.

The loop no longer has a cbk_error. Callback code is expected to try/catch
callbacks (or use callback.tryCall) and handle errors accordingly.
2025-05-05 19:03:45 +08:00
Pierre Tachoire
7f2506d8a6 Merge pull request #598 from lightpanda-io/unused_imports
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (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
wpt / 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
remove unused code, mostly imports
2025-05-05 12:07:29 +02:00
Karl Seguin
7c2f7b6338 Merge pull request #595 from lightpanda-io/env_debug_ergonomics
Improve the debug ergonomics of the Env generic.
2025-05-05 16:22:05 +08:00
Karl Seguin
5f05de30a6 Improve the debug ergonomics of the Env generic.
Previously, we were passing our WebAPIs directly as an anonymous tuple. This
resulted in Env(T) having an _awful_ name - a name composed of hundreds of
classes.

By wrapping the anonymous tuple into a normal struct, the Env now gets a sane
name which helps improve stack traces (and profiling, and debugging, ...)
2025-05-05 16:03:55 +08:00
Pierre Tachoire
7741de7ae0 Merge pull request #597 from lightpanda-io/fix_undefined_access
Remove undefined that causes crash
2025-05-05 09:54:54 +02:00
Karl Seguin
d4c8e8c50e Merge pull request #592 from lightpanda-io/isolated-polyfill-+-create-when-needed
Isolated polyfill & create world when needed
2025-05-05 15:03:05 +08:00
Pierre Tachoire
bf36ff9cb9 Merge pull request #593 from lightpanda-io/crypto_web_api
add crypto web api
2025-05-05 08:56:27 +02:00
Pierre Tachoire
8eadccdee2 Merge pull request #587 from lightpanda-io/dom-setchildnodes
cdp: dispatch DOM.setChildNodes event for search results
2025-05-05 08:56:04 +02:00
Kilari Teja
b32839292c Support Data URI in scripts tags (#596)
* Support text/javascript mime type

* Support base64 encoded scripts

Related to https://github.com/lightpanda-io/browser/issues/412
2025-05-05 14:48:21 +08:00
Pierre Tachoire
2402443dcc cdp: add comments on setChildNodes event
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2025-05-05 08:48:04 +02:00
sjorsdonkers
9f72c98967 Error on null page/scope 2025-05-05 08:46:33 +02:00
sjorsdonkers
f6f744aea1 Fix gc_hints not being send 2025-05-05 08:46:33 +02:00
sjorsdonkers
cddc55694a load polyfills on creation 2025-05-05 08:46:32 +02:00
sjorsdonkers
8930e2f06e isolated polyfill + create when needed 2025-05-05 08:46:32 +02:00
Karl Seguin
b8e5e130b9 remove unused code, mostly imports 2025-05-05 13:29:41 +08:00
Karl Seguin
a8c5087a38 Remove undefined that causes crash
These values are set to undefined, and used (in the item function) before ever
being set. Causes crashes in release mode.
2025-05-04 21:18:30 +08:00
Karl Seguin
d9f21e0475 add empty cases to empty test suite (#594)
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (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
wpt / 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
2025-05-03 14:11:39 +08:00
Karl Seguin
ca3fa3dc40 Rework WPT runner (#589)
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
* Rework WPT runner

We have no crashing tests, remove safe mode. Allows better re-use of arenas,
and if we do introduce a crash, it won't be easy to ignore. Could allow for
re-using the environment across tests to further improve performance.

Remove console now that we have a working console api.

* Update workflows, add summary

Remove --safe option from WPT workflows (it's no longer valid)

Include a total test/case summary when --summary or --text (default) is used.

* remove wpt --safe flag from Makefile

* handle tests in the root of the test folder

* Fix a couple possible segfaults base on strange usage (WPT stuff)

* generate proper JSON

* generate proper JSON (for real this time?)

* fix tag type check
2025-05-03 07:53:02 +08:00
Karl Seguin
ddd0a42b26 add crypto web api 2025-05-03 07:52:12 +08:00
Pierre Tachoire
f884627927 cdp: sent setchildnodes once per node 2025-05-02 22:10:26 +02:00
Pierre Tachoire
9373cf9cf6 cdp: refacto sendChildNodes 2025-05-02 21:55:14 +02:00
Pierre Tachoire
f04030904e cdp: fix tests for setchildnodes 2025-05-02 15:55:49 +02:00
Pierre Tachoire
271b2a0417 Merge pull request #591 from lightpanda-io/element_matches
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
add Element.matches web api
2025-05-02 11:57:55 +02:00
Karl Seguin
a4f7393fc8 Merge pull request #590 from lightpanda-io/zig_fmt
zig fmt
2025-05-02 16:40:24 +08:00
Karl Seguin
8f851beda1 add Element.matches web api 2025-05-02 16:30:49 +08:00
Karl Seguin
4489efa8d9 zig fmt 2025-05-02 16:03:13 +08:00
Pierre Tachoire
8b9084cb73 cdp: dispatch the correct dom hierarchy wit setChildNodes 2025-05-01 19:42:04 +02:00
Pierre Tachoire
1146453dc2 cdp: add session to setChildNodes event 2025-05-01 16:51:02 +02:00
Pierre Tachoire
bd54395948 Merge pull request #588 from lightpanda-io/custom_events
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
Add CustomEvent api
2025-05-01 16:29:57 +02:00
Karl Seguin
89ac27ba97 Add CustomEvent api 2025-05-01 19:33:22 +08:00
Karl Seguin
74eaee53a4 Merge pull request #585 from lightpanda-io/union_params
Support union parameters
2025-05-01 19:20:21 +08:00
Karl Seguin
20e4261aa7 Support union parameters
There's ambiguity in mapping due to the flexible nature of JavaScript. Hopefully
most types are unambiguous, like a string or am *parser.Node.

We need to "probe" each field to see if it's a possible candidate for the JS
value. On a perfect match, we stop probing and set the appropriate union field.
There are 2 levels of possible matches: candidate and coerce. A "candidate"
match has higher precedence. This is necessary because, in JavaScript, a lot
of things can be coerced to a lot of other, seemingly wrong, things.

For example, say we have this union:

a: i32,
b: bool,

Field `a` is a perfect match for the value 123. And field b is a coerce match
(because, yes, 123 can be coerced to a boolean). So we map it to `a`.

Field `a` is a candidate match for the value 34.2, because float -> int are both
"Numbers" in JavaScript. And field b is a coerce match. So we map it to `a`.

Both field `a` and field `b` are coerce matches for "hello". So we map it to `a`
because it's declared first (this relies on how Zig currently works, but I don't
think the ordering of type declarations is guaranteed, so that's an issue).
2025-05-01 18:31:55 +08:00
Pierre Tachoire
312189fbde Merge pull request #586 from lightpanda-io/cancel_via_lookup
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
Change the Linux cancel implementation to match MacOS'
2025-05-01 10:18:35 +02:00
Karl Seguin
d05063ec61 Merge pull request #579 from lightpanda-io/console
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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 console web api
2025-05-01 09:50:37 +08:00
Karl Seguin
47c14db54c Merge pull request #577 from lightpanda-io/unified_intrusive_events
Unify the Zig and JS events using an intrusive node.
2025-05-01 09:50:19 +08:00
Karl Seguin
f0e0650244 Merge pull request #568 from lightpanda-io/notifications
Introduce more general notification capabilities
2025-05-01 09:50:06 +08:00
Pierre Tachoire
d2a68e62e9 cdp: add attributes to the node's writer 2025-04-30 15:56:06 +02:00
Pierre Tachoire
09fbbc1e17 netsurf: node's attributes can be null 2025-04-30 15:55:34 +02:00
Karl Seguin
8971822247 Change the Linux cancel implementation to match MacOS'
cancel on linux was a "real" cancel, but the implementation was unsafe. It took
whatever `id` it was given and @ptrFromInt'd it. This is problematic since the
`id` is user-supplied with virtually no validation.

Using the existing MacOS canceled lookup seems both easier and safer than trying
to validate the cancellation id.
2025-04-30 21:41:52 +08:00
Karl Seguin
1f0d1920bf Merge branch 'main' into unified_intrusive_events 2025-04-30 21:32:34 +08:00
Karl Seguin
cb7c8502b0 add console web api 2025-04-30 20:50:31 +08:00
Karl Seguin
27d1f79839 Merge pull request #583 from lightpanda-io/share-state-and-global-with-the-isolated
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
Share underlying DOM global Window with the isolated World
2025-04-30 19:55:14 +08:00
sjorsdonkers
83ef21e699 page handlescope clarification 2025-04-30 12:01:56 +02:00
Karl Seguin
6c592669da Introduce more general notification capabilities
Replaces the existing, very specialized Notification with something more
general.

Currently, the existing page_navigate and page_navigated have been migrated.

Telemetry's page navigation event now also hooks into these events to generate
the telemetry record.
2025-04-30 17:33:51 +08:00
Pierre Tachoire
88f7687646 cdp: dispatch DOM.setChildNodes on performSearch 2025-04-30 09:19:59 +02:00
Pierre Tachoire
f12a527ae3 cdp: add ParentId to Node.Writer 2025-04-30 09:01:56 +02:00
sjorsdonkers
7dde0be043 share sessionstate and underlying DOM global with the isolated 2025-04-29 23:17:39 +02:00
Pierre Tachoire
2910f4f527 Merge pull request #581 from lightpanda-io/svgelement_dummy2
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
Svgelement dummy2
2025-04-29 18:50:16 +02:00
Pierre Tachoire
93c0df33c2 Merge pull request #578 from lightpanda-io/scope_tightening
Reorganize v8 contexts and scope
2025-04-29 18:46:31 +02:00
sjorsdonkers
7d9f6eef27 instanceof svgelement test 2025-04-29 18:11:47 +02:00
sjorsdonkers
7d742d62b8 SVGElement dummy 2025-04-29 18:11:47 +02:00
sjorsdonkers
4db80cb9e7 Adopt state into the isolated world 2025-04-29 18:10:55 +02:00
Pierre Tachoire
addfbcb68f Merge pull request #582 from lightpanda-io/remove_main_shell
Remove unused main_shell
2025-04-29 17:31:42 +02:00
sjorsdonkers
fac46d9d0b Redo resolveNode 2025-04-29 16:56:50 +02:00
sjorsdonkers
e38ff08de2 Remove unused main_shell 2025-04-29 14:43:14 +02:00
sjorsdonkers
c31e2d91dd Remove global scope 2025-04-29 11:59:14 +02:00
Karl Seguin
7309fec51d Fully fake contextCreated
emit contextCreated when it's needed, not when it actually happens.

I thought we could make this sync-up, but we'd need to create 3 contexts to
satisfy both puppeteer and chromedp. So rather than having it partially
driven by notifications from Browser, I rather just fake it all for now.
2025-04-29 13:29:42 +08:00
Karl Seguin
2e01fa738a Make undefined->null safer, and apply the same trick to BrowserContext 2025-04-29 11:28:43 +08:00
Karl Seguin
9044925f32 emit context created on createTarget event for chromedp 2025-04-29 10:58:23 +08:00
Karl Seguin
2d5ff8252c Reorganize v8 contexts and scope
- Pages within the same session have proper isolation
  - they have their own window
  - they have their own SessionState
  - they have their own v8.Context

- Move inspector to CDP browser context
  - Browser now knows nothing about the inspector

- Use notification to emit a context-created message
  - This is still a bit hacky, but again, it decouples browser from CDP
2025-04-29 10:22:08 +08:00
Karl Seguin
072110481f Unify the Zig and JS events using an intrusive node.
The approach borrows heavily from Zig's new LinkedList API.

The main benefit is that it unifies how event callbacks are done. When the
Page.windowClick event was added, the Event structure was changed to a union,
supporting a distinct Zig and JS event.

This new approach more or less treats everything like a Zig event. A JS event
is just a Zig struct that has a Env.Callback which it can invoke in its handle
method.

The intrusive nature of the EventNode means that what used to be 1 or 2
allocations is now 0 or 1.

It also has the benefit of making netsurf completely unaware of Env.Callbacks.
2025-04-26 22:22:34 +08:00
Pierre Tachoire
0fb0532875 Merge pull request #562 from lightpanda-io/mutation_observer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (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
wpt / 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
Improve MutationObserver
2025-04-25 10:53:48 +02:00
Pierre Tachoire
d8dd94c679 Merge pull request #569 from lightpanda-io/make_cdp_less_generic
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
Make CDP less generic.
2025-04-25 09:50:17 +02:00
Karl Seguin
f3d7736acf Update src/browser/dom/mutation_observer.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2025-04-25 15:48:46 +08:00
Pierre Tachoire
8fbf5590f8 Merge pull request #573 from lightpanda-io/typed_arrays
add support for mapping integer typed arrays into zig slices
2025-04-25 09:30:44 +02:00
Pierre Tachoire
b8ac93045e Merge pull request #574 from lightpanda-io/enable_icu
initialize ICU
2025-04-25 09:29:34 +02:00
Karl Seguin
89fea9b4df initialize ICU
This makes functions like new Intl.DateTimeFormat() not crash.
2025-04-25 13:15:38 +08:00
Karl Seguin
a3323dc8a7 add support for mapping integer typed arrays into zig slices 2025-04-25 13:01:43 +08:00
Karl Seguin
ba0505c13c Merge pull request #571 from lightpanda-io/remove-log.zig
Remove log.zig
2025-04-25 08:47:30 +08:00
Karl Seguin
dd8432e8fd Merge pull request #572 from lightpanda-io/framenavigated-order
cdp: dispatch Page.frameNavigated before DOM.documentUpdated
2025-04-25 08:47:07 +08:00
Pierre Tachoire
11c7f57745 cdp: dispatch Page.frameNavigated before DOM.documentUpdated
chromedp client expects to receive Page.frameNavigated before
DOM.documentUpdated.
2025-04-24 18:28:43 +02:00
sjorsdonkers
89a3fac316 log.zig does not appear to be used 2025-04-24 15:17:16 +02:00
Karl Seguin
b0b3e92600 remove Browser.EnvType 2025-04-24 19:48:27 +08:00
Karl Seguin
1fca035cfe Make CDP less generic.
It's still generic over the client - we need to assert messages written to and
be able to send specific commands, but it's no longer generic over Browser/
Session/Page/etc..
2025-04-24 18:06:55 +08:00
Karl Seguin
4c89bb0e0a Improve MutationObserver
- Fix get_removedNodes (it was returning addedNodes)
- get_removedNodes and get addedNodes now return references
- used enum for dispatching and clean up dispatching in general
- Remove MutationRecords and simply return an array
  - this allows the returned records to be iterable (as they should be)
  - jsruntime ZigToJs will now map a Zig array to a JS array
-Rely on default initialize of NodeList
-Batch observed records
 - Callback only executed when call_depth == 0
 - Fixes crashes when a MutationObserver callback mutated the nodes being
   observes.
 - Fixes some WPT issues, but Netsurf's mutationEventRelatedNode does not
   appear to be to spec, so most tests fail.
 - Allow zig methods to execute arbitrary code when call_depth == 0
   - This is a preview of how I hope to make XHR not crash if the CDP session
     ends while there's still network activity
2025-04-24 17:40:37 +08:00
Pierre Tachoire
332508f563 Merge pull request #567 from lightpanda-io/kind_before_deinit
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
access the executor kind before it becomes invalid
2025-04-24 10:43:37 +02:00
Karl Seguin
158d11e93c access the executor kind before it becomes invalid 2025-04-24 16:36:04 +08:00
Pierre Tachoire
18a49601a0 Merge pull request #566 from lightpanda-io/null_prefix_namespace
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
pass null namespace/prefix to libdom
2025-04-24 09:27:03 +02:00
Karl Seguin
b971b4754f update libdom to latest version, fixing null ptr usage and stack overflow 2025-04-24 15:18:22 +08:00
Pierre Tachoire
cfef22257e Merge pull request #560 from lightpanda-io/remove_arena_frees
Remove unnecessary cleanup when we know we have an arena
2025-04-24 09:04:46 +02:00
Karl Seguin
3153d8ee8c pass null namespace/prefix to libdom 2025-04-24 11:52:01 +08:00
sjorsdonkers
b2a975fe4a remove executionContextCreated
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
2025-04-23 17:00:22 +02:00
sjorsdonkers
b2ba505954 Check if it works with v8 97bcfb6 2025-04-23 17:00:22 +02:00
sjorsdonkers
a1b673175a errdefer in the right scope 2025-04-23 17:00:22 +02:00
sjorsdonkers
d666de07a7 test scaffolding 2025-04-23 17:00:22 +02:00
sjorsdonkers
64508cec61 Executor World kind 2025-04-23 17:00:22 +02:00
sjorsdonkers
e0bcb625c2 browsercontext arena 2025-04-23 17:00:22 +02:00
sjorsdonkers
9534e765e5 refix page.contextCreated 2025-04-23 17:00:22 +02:00
sjorsdonkers
39124d2878 text fix 2025-04-23 17:00:22 +02:00
sjorsdonkers
9ae4d66194 page cleanup 2025-04-23 17:00:22 +02:00
sjorsdonkers
09850d7500 Fix executor used in resolveNode 2025-04-23 17:00:22 +02:00
sjorsdonkers
8897d9179c isolated world 2025-04-23 17:00:22 +02:00
Karl Seguin
2d1b9d64bd Remove unnecessary cleanup when we know we have an arena
Change a few old alloc+memcpy to dupe

Some other smaller cleanups.
2025-04-23 17:52:13 +08:00
Pierre Tachoire
e603a1707c Merge pull request #559 from lightpanda-io/processing_instruction_clone
Manually "clone" processing_instruction
2025-04-23 09:51:33 +02:00
Pierre Tachoire
6b1e7a1c5d Merge pull request #558 from lightpanda-io/node_is_equal
Implement custom isEqualNode
2025-04-23 09:50:43 +02:00
Pierre Tachoire
5acd4b5fd4 Revert "browser: temporary ignore mime sniff"
This reverts commit 0b2c4679fd.
2025-04-23 09:26:10 +02:00
Pierre Tachoire
9e88adb0da Merge pull request #557 from lightpanda-io/fix_peek
peek must check existing data first
2025-04-23 09:25:59 +02:00
Karl Seguin
69eaf0d338 Manually "clone" processing_instruction
When you clone a processing_node via the node_clone_node, or directly via the
processing_node copy, you end up in _dom_pi_copy:
da8b967905/src/core/pi.c (L104)

For whatever, reason, the node created here gets a vtable that doesn't seem
compatible with how we cast vtables in netsurf.zig. For now, a simple fix is
to create a new new and copy the attributes over.

Fixes https://github.com/lightpanda-io/browser/issues/123 and a WPT crash.
2025-04-23 13:31:05 +08:00
Karl Seguin
680de2dca1 Implement custom isEqualNode
Netsurf's dom_node_is_equal appears to be both unsafe and incorrect. It's unsafe
because various node types don't have the dom_node_get_attributes implementation
so they default to setting the node attributes to null:
https://github.com/lightpanda-io/libdom/blob/master/src/core/node.c#L658

However, it doesn't do a NULL check when comparing them, so it crashes:
da8b967905/src/core/namednodemap.c (L312)

Furthermore, specific nodes need to be compared using specific attributes/values.

This PR fixes a WPT crash.
2025-04-23 09:59:57 +08:00
Karl Seguin
837188f8d1 peek must check existing data first 2025-04-23 08:28:20 +08:00
Pierre Tachoire
4a696b4053 Merge pull request #556 from lightpanda-io/mime-sniff-skip
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
browser: temporary ignore mime sniff
2025-04-22 18:55:57 +02:00
Pierre Tachoire
0b2c4679fd browser: temporary ignore mime sniff 2025-04-22 18:47:52 +02:00
Pierre Tachoire
5a08c92d02 Merge pull request #553 from lightpanda-io/mime_sniffing
Try to sniff the mime type based on the body content
2025-04-22 17:25:29 +02:00
Pierre Tachoire
faf93441f6 Merge pull request #555 from lightpanda-io/wpt_filter_non_tests
Don't [try] to run non-tests
2025-04-22 17:00:19 +02:00
Karl Seguin
8aa3608a3c Don't [try] to run non-tests
Currently, we treat every .html file in tests/wpt as-if it's a test. That
isn't always the case. wpt has a manifest tool to generate a manifest (in
JSON) of the tests, we should probably use that (but it's quite large).

This PR filters out two groups. First, everything in resources/ appears to
be things to _run_ the tests, not actual tests.

Second, any file without a "testharness.js" doesn't appear to be a test
either. Note that WPT's own manifest generator looks at this:
43a0615361/tools/manifest/sourcefile.py (L676)
(which is used later in the file to determine the type of file).
2025-04-22 21:05:12 +08:00
Pierre Tachoire
9727a9d000 Merge pull request #547 from lightpanda-io/jsruntime_arenas
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
Re-introduce call_arena
2025-04-22 13:58:16 +02:00
Pierre Tachoire
1b74289c43 Merge pull request #543 from lightpanda-io/describeNode2
descibeNode for new js runtime
2025-04-22 13:57:18 +02:00
sjorsdonkers
a698ff8309 describeNode feedback 2025-04-22 13:49:01 +02:00
sjorsdonkers
5026c48805 Update zig-v8 to v0.1.18 2025-04-22 13:49:00 +02:00
sjorsdonkers
2ac63b6985 describeNode 2025-04-22 13:49:00 +02:00
Karl Seguin
114e11f52a Partially revert some changes.
The call_arena is still re-added, and the call_depth is still used, but
the call_arena and scope_arenas are no longer part of the Env - they remain on
the Executor.

This is to accommodate upcoming changes where multiple executors will exist at
once. While the shared allocators _might_ have been safe in some cases, the
performance gains don't justify the risk of having 2 executors interacting in a
way where sharing the allocators would cause issues.
2025-04-22 16:56:26 +08:00
Karl Seguin
3277d1baac Re-introduce call_arena
Because of callbacks, calls into Zig can be nested. Previously, the call_arena
was reset after _every_ call. When calls are nested, this doesn't work - the
nested call resets the arena, which the caller might still need. A `call_depth`
integer was added to the Executor. Each call starts by incrementing the
call_depth and, on deinit, decrements the call_depth. The call_arena is only
reset when the call_depth == 0. This could result in lower memory use.

Also promoted the call_arena and scope_arena to the Env. Practically speaking,
nothing has changed, since they're still reset under the exact same conditions.
However, when an executor ends and a new one is started, it can now reuse the
retained_capacity of the previous arenas. This should result in fewer
allocations.
2025-04-22 15:20:37 +08:00
Pierre Tachoire
f3d8ec040c Merge pull request #549 from lightpanda-io/type_error_on_non_zig_values
Return TypeError if trying to turn an unknown v8.Object into a toa
2025-04-22 09:14:03 +02:00
Pierre Tachoire
0a29e9b3cf Merge pull request #548 from lightpanda-io/namednodemap_indexed_get
add indexed_get to namednodemap
2025-04-22 09:13:05 +02:00
Pierre Tachoire
4b7c17ac03 Merge pull request #546 from lightpanda-io/jsruntime_js_to_null_terminated_string
Support binding JS strings to [:0]const u8
2025-04-22 09:11:36 +02:00
Pierre Tachoire
1849f4c11d Merge pull request #544 from lightpanda-io/token_list_iterators
Add missing TokenList APIs
2025-04-22 09:03:19 +02:00
Karl Seguin
b9f61466ba Try to sniff the mime type based on the body content
Synchronous body reader now exposes a peek() function to get the first few bytes
from the response body. This will be no less than 100 bytes (assuming the body
is that big), but could be more. Streaming API, via res.next() continues to work
as-is even if peek() is called.

Introduce Mime.sniff() that detects a few common types - the ones that we care
about right now - from the body content.
2025-04-22 10:58:26 +08:00
Karl Seguin
d8fa9b8c4f Return TypeError if trying to turn an unknown v8.Object into a toa 2025-04-20 12:47:28 +08:00
Karl Seguin
42bc80e5b5 add indexed_get to namednodemap 2025-04-19 21:28:16 +08:00
Karl Seguin
9f7446ba56 use allocSentinel (which i didn't know about) 2025-04-19 17:25:01 +08:00
Karl Seguin
7bdea1befa Support binding JS strings to [:0]const u8
Some APIs need a null-terminated string. Currently, they have to ask for a
`[]const u8` and then convert it to a `[:0]const u8`. This is 2 allocations: 1
for jsruntime to get the `[]const u8` from v8, and then one to get the [:0]. By
supporting `[:0]const u8` directly, this is now a single allocation.
2025-04-19 16:19:46 +08:00
Pierre Tachoire
66ec087416 Merge pull request #516 from karlseguin/javascript_anchor_click
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (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
wpt / 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
Add zig listener support to netsurf event handler
2025-04-18 15:17:37 +02:00
Karl Seguin
9b4d1d442e Allow this argument to TokenList forEach
JsObject can now be used as a normal parameter. It'll receive the opaque value.
This is largely useful when a Zig function takes an argument which it needs
to pass back into a callback.

JsThis is now a thin wrapper around JsObject for functions that was the JsObject
of the receiver. This is for advanced usage where the Zig function wants to
manipulate the v8.Object that represents the zig value. postAttach is an example
of such usage.
2025-04-18 20:38:52 +08:00
sjorsdonkers
16a30fa3b7 enum as subtype
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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-04-18 13:46:54 +02:00
sjorsdonkers
1cd3ebfc3f remove tnames 2025-04-18 13:46:54 +02:00
sjorsdonkers
fd170df98f node subtypes 2025-04-18 13:46:54 +02:00
Karl Seguin
a2291b0713 Add missing TokenList APIs
Add value setter, keys(), values(), entries() and forEach().

Like nodelist, forEach still doesn't support `this` arg (gotta think about
how to do this).

I think these iterable methods are missing in a few places, so I added a
generic Entries iterator and a generic Iterable.

jsruntime will now map a Zig tuple to a JS array.
2025-04-18 19:10:37 +08:00
Karl Seguin
3134ff81f4 JS clicks and MouseInput clicks trigger page navigation 2025-04-18 16:24:04 +08:00
Karl Seguin
072bc514f4 Add zig listener support to netsurf event handler
Add _click handler to HTMLElement

Register zig click listener on document.

Largely waiting on https://github.com/lightpanda-io/browser/pull/501/files to
finalize the placeholders.
2025-04-18 16:20:46 +08:00
Pierre Tachoire
581a79f3fc Merge pull request #540 from lightpanda-io/make-e2e
make: add end2end target
2025-04-18 09:49:08 +02:00
Pierre Tachoire
cccb8e9645 Merge pull request #538 from lightpanda-io/node_class_attributes
Add Node.$NODE_TYPE class attributes
2025-04-18 09:48:11 +02:00
Pierre Tachoire
646fcafa62 Merge pull request #541 from lightpanda-io/subtype_fix
Change TypeLookup values from simple index (usize) to a TypeMeta
2025-04-18 09:47:09 +02:00
Karl Seguin
615453a687 Change TypeLookup values from simple index (usize) to a TypeMeta
TypeMeta constains the index and the subtype. This allows retrieving the subtype
based on the actual value being bound, as opposed to the struct type. (I.e. it
returns the correct subtype when a Zig class is a proxy for another using the
Self declaration).

Internally store the subtype as an enum. Reduces @sizeOf(TaggedAnyOpaque) from
32 to 16.

Finally, sub_type renamed to subtype for consistency with v8.
2025-04-18 09:56:08 +08:00
Karl Seguin
361a1a21ac zig fmt :| 2025-04-18 00:10:40 +08:00
Karl Seguin
e3e3311dd0 add deprecated node types (both Chrome and FF have them) 2025-04-18 00:10:03 +08:00
Pierre Tachoire
74fa9a6b2b ci: use the demo go runner 2025-04-17 17:57:19 +02:00
Pierre Tachoire
b62faef520 make: add end2end target 2025-04-17 17:41:29 +02:00
Karl Seguin
74391d59a5 Add Node.$NODE_TYPE class attributes 2025-04-17 21:41:28 +08:00
Pierre Tachoire
1c08b3e5e4 Merge pull request #534 from lightpanda-io/mutable_response_header_value
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
Make HTTP Response header values mutable
2025-04-17 13:10:33 +02:00
Pierre Tachoire
8c489c2131 Merge pull request #506 from lightpanda-io/jsruntime
replace zig-js-runtime
2025-04-17 13:09:44 +02:00
Pierre Tachoire
19976939b7 readme: fix build-v8 target 2025-04-17 13:01:01 +02:00
Karl Seguin
4e1659b98d Disable the call arena (for now)
The call arena doesn't consider nested calls (like, from callbacks). Currently
when a "call" ends, the arena is cleared. But in a callback, if we do that,
the memory for the containing code is no longer valid, even though it's still
executing.

For now, use the existing scope_arena, instead of the call_arena. In the future
we need to track the call-depth, and only reset the call_arena when we're done
with a top-level statement.

Also:
-Properly handle callback errors
-Increase wpt file size
-Merge latest loop.zig from zig-js-runtime.
2025-04-17 18:38:47 +08:00
Pierre Tachoire
26ef8deca5 Merge pull request #535 from lightpanda-io/wsl-support-note
WSL support note
2025-04-17 11:01:09 +02:00
Karl Seguin
4e5fe5ae1a Merge pull request #536 from lightpanda-io/jsruntime-imp
test: re-introduce js source name
2025-04-17 16:18:48 +08:00
Pierre Tachoire
7f308f59b4 test: re-introduce js source name
Having a js source name is useful to detect where the error comes from.

Using `null` generates messages with `<anonymous>` source name.
eg. `ReferenceError: report is not defined\n    at <anonymous>:1:1`
vs. `ReferenceError: report is not defined\n    at teststatus:1:1`
2025-04-17 10:08:13 +02:00
Karl Seguin
f4e8bb6c66 Re-introduce postAttach
index_get seems to be ~1000x slower than setting the value directly on the
v8.Object. There's a lot of information on "v8 fast properties", and values
set directly on objects seem to be heavily optimized. Still, I can't imagine
indexed properties are always _that_ slow, so I must be doing something wrong.
Still, for now, this brings back the original functionality / behavior / perf.

Introduce the ability for Zig functions to take a Env.JsObject parameter. While
this isn't currently being used, it aligns with bringing back the postAttach /
JSObject functionality in main.

Moved function *State to the end of the function list (making it consistent with
getters and setters). The optional Env.JsObject parameter comes after the
optional state.

Removed some uncessary arena deinits from a few webapis.
2025-04-17 09:26:37 +08:00
Karl Seguin
e3638053d0 better error messages in WPT report (in line with what main branch is doing) 2025-04-16 19:34:36 +08:00
Karl Seguin
d688d8812d add missing try/catch around loop wait for wpt tests 2025-04-16 19:20:35 +08:00
Karl Seguin
4a6bf38666 ResponseHeader.get should return mutable slice 2025-04-16 16:54:28 +08:00
Sjors
f532b62913 missing space 2025-04-16 10:41:20 +02:00
sjorsdonkers
0080c8457f WSL support note 2025-04-16 10:05:52 +02:00
Karl Seguin
613904e3a4 Make HTTP Response header values mutable
The HTTP response values _are_ mutable, but because we're using std.http.Header
the type is a `[]const u8`. This introduce a custom `Header` type where the
value is `[]u8`.

The goal is largely to allow more efficient value-comparison, by allowing
calling code to lower-case in-place. I specifically have the Mime parser in
mind:

25dcae7648/src/browser/mime.zig (L134)
2025-04-16 14:05:21 +08:00
Karl Seguin
753a093689 zig fmt :| 2025-04-15 21:16:20 +08:00
Karl Seguin
ea6f8ce4d9 Add more tests
Remove index and named setters, since they aren't working, and they aren't
currently needed.
2025-04-15 20:37:59 +08:00
Karl Seguin
180359e148 zig build test --json to get duration/memory stats 2025-04-15 18:49:39 +08:00
Karl Seguin
5816443ad3 improve XHR test reliability 2025-04-15 18:24:43 +08:00
Karl Seguin
e9fce9223e add some debug lines to see if we can fix the github action 2025-04-15 15:42:55 +08:00
Karl Seguin
f6c43eaf9c Fix dockerfile (hopefully)
Add dummy --json stats output to tests

Comment typos fixed

Add make get-v8, build-v8 and build-v8-dev make targets
2025-04-15 15:18:06 +08:00
Karl Seguin
8af71be551 Import some of the zig-js-runtime env tests
- Fix passing nothing into variadic (i.e. slice) parameter
- Optimize @sizeOf(T) == 0 types by avoiding uncessary allocations
  (something zig-js-runtime is doing)
2025-04-15 15:18:06 +08:00
Karl Seguin
9e36702eb2 Improve documentation
Remove Env from caller, and don't store Env in isolate. We don't need it, we
can execute all runtime code from the Executor (which we store a pointer to
in the v8.Context)
2025-04-15 15:18:06 +08:00
Karl Seguin
cda6f89dba work on fixing github workflows 2025-04-15 15:18:06 +08:00
Karl Seguin
b8d7744563 replace zig-js-runtime 2025-04-15 15:18:04 +08:00
Karl Seguin
25dcae7648 Merge pull request #529 from lightpanda-io/document-cookie
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (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
wpt / 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
Document cookie
2025-04-11 23:34:44 +08:00
Pierre Tachoire
ee6382ef03 dom: use cookie jar's allocator to parse cookie 2025-04-11 16:23:03 +02:00
Pierre Tachoire
0310192660 dom: assume we are using an arena for cookie 2025-04-11 14:27:08 +02:00
Pierre Tachoire
c88bc65379 cookie: use a ; w/o space for cookie separator in requests 2025-04-11 12:40:16 +02:00
Pierre Tachoire
37340dc549 dom: implement document.cookie 2025-04-11 12:19:11 +02:00
Pierre Tachoire
9b6764a852 Merge pull request #527 from lightpanda-io/update-cla
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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 bornlex to cla whitelist
2025-04-11 11:14:46 +02:00
Pierre Tachoire
b176857b8d add bornlex to cla whitelist 2025-04-11 11:14:01 +02:00
Pierre Tachoire
f034065247 Merge pull request #520 from lightpanda-io/navigate_notifications
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
Communicate page navigation state via notifications
2025-04-10 13:42:30 +02:00
Karl Seguin
64bd4dee38 Merge pull request #523 from lightpanda-io/url-about-blank
url: accept about:blank
2025-04-10 19:15:04 +08:00
Pierre Tachoire
22307239ae url: accept about:blank 2025-04-10 13:00:14 +02:00
Karl Seguin
3fc7ffadbf rename ts => timestamp, ctx => notify_ctx 2025-04-10 18:27:14 +08:00
Pierre Tachoire
b87a80a32d Merge pull request #521 from lightpanda-io/remove_wpt_env_wait
Remove the WPT js_env.wait() on error.
2025-04-10 11:40:33 +02:00
Karl Seguin
c775de260a Remove the WPT js_env.wait() on error.
40c0c7d421

Makes it unecessary as wait is now always called on deinit.
2025-04-10 16:30:44 +08:00
Karl Seguin
30fd358286 improve playwright pafe lifecycle message compatibility 2025-04-10 16:07:31 +08:00
Karl Seguin
71c3d484a9 Communicate page navigation state via notifications
In order to support click handling on anchors from JavaScript, we need some hook
from the page/session to the CDP instance. This first phase adds notifications
in page.navigate, as well as a primitive notification hook to the session.

CDP's existing Page.navigate uses this new notifiation system.
2025-04-10 14:25:19 +08:00
Pierre Tachoire
66bac32e33 Merge pull request #519 from lightpanda-io/url
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
Add URL struct
2025-04-09 16:43:44 +02:00
Pierre Tachoire
4f0ea888ef Merge pull request #513 from lightpanda-io/resolveNode
Resolve node
2025-04-09 15:00:35 +02:00
Pierre Tachoire
bc1a83d04a Update src/cdp/domains/dom.zig 2025-04-09 14:46:53 +02:00
sjorsdonkers
32d9fc0d32 Pass objectGroup as groupName 2025-04-09 13:40:00 +02:00
Karl Seguin
41bd3704ef update lightpanda and wpt URL usage 2025-04-09 19:21:59 +08:00
Karl Seguin
be75b5b237 Add URL struct
Combine uri + rawuri into single struct.

Try to improve ownership around URIs and URI-like things.
 - cookie & request can take *const std.Uri
   (TODO: make them aware of the new URL struct?)
 - Location (web api) should own its URL (web api URL)
 - Window should own its Location

Most of these changes result in (a) a cleaner Page and (b) not having to carry
around 2 nullable objects (URI and rawuri).
2025-04-09 18:19:07 +08:00
sjorsdonkers
3a7da6665f unittest scaffolding 2025-04-09 11:33:44 +02:00
sjorsdonkers
2f47e04de7 Use findOrAddValue for precise JsValue 2025-04-09 11:33:41 +02:00
sjorsdonkers
7dc3add5fd reolveNode WIP 2025-04-09 11:32:23 +02:00
Pierre Tachoire
8b296534a4 Merge pull request #517 from lightpanda-io/wpt-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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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 fix
2025-04-09 08:39:18 +02:00
Karl Seguin
f9c4cefe59 Update zig-js-runtime, wait for loop on wpt error
Updates zig-js-runtime to latest, reverting the loop reset change. This solves
the introduced memory leak.

On WPT error, call js_env.wait() to ensure all pending events are completed.
Without this, on error, the code is likely to crash as the timeout callback
executes AFTER env.deinit() is called. This is now possible to do safely on
Mac now that cancel is pseudo-implemented.
2025-04-09 09:01:04 +08:00
Pierre Tachoire
d772eaf4a2 upgrade zig-jsruntime 2025-04-08 17:34:56 +02:00
Pierre Tachoire
27ec1a13da wpt: add missing renderer 2025-04-08 17:34:18 +02:00
Pierre Tachoire
07e8dfa257 Merge pull request #501 from karlseguin/renderer
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
Add a dumb renderer to get coordinates
2025-04-08 17:12:49 +02:00
Karl Seguin
0fbf48ab4d actually dispatch click 2025-04-08 22:51:19 +08:00
Karl Seguin
f38a0d2d67 Remove BrowserContext URL
Add BrowserContext.getURL which gets the URL from the session.page.
2025-04-08 22:51:17 +08:00
Karl Seguin
b76875bf5d use netsurf's mousevent 2025-04-08 22:43:53 +08:00
Karl Seguin
0253de80de Add a dumb renderer to get coordinates
FlatRenderer positions items on a single row, giving each a height and width of
1.

Added getBoundingClientRect to the DOMelement which, when requested for the
first time, will place the item in with the renderer.

The goal here is to give elements a fixed position and to make it easy to map
x,y coordinates onto an element. This should work, at least with puppeteer,
since it first requests the boundingClientRect before issuing a click.
2025-04-08 22:43:53 +08:00
Pierre Tachoire
647575261e Merge pull request #515 from karlseguin/html_document_subtype
add 'node' subtype to htmldocument
2025-04-08 15:45:40 +02:00
Pierre Tachoire
3c2b348ce5 Merge pull request #502 from lightpanda-io/cdp_node_children
Cdp node children
2025-04-08 15:45:16 +02:00
Karl Seguin
8aef6ca372 add 'node' subtype to htmldocument 2025-04-08 10:40:52 +08:00
Karl Seguin
0139437c3d Wrap getDocument response in a root object 2025-04-08 10:05:32 +08:00
Pierre Tachoire
a7b91ee57d Merge pull request #514 from lightpanda-io/js-kind
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
browser: script with type text/javascript are js
2025-04-07 22:22:35 +02:00
Pierre Tachoire
ad0117e060 browser: script with type text/javascript are js 2025-04-07 22:06:39 +02:00
Pierre Tachoire
309d70c142 Merge pull request #509 from lightpanda-io/browser-no-content-type
browser: assume no-content type is html
2025-04-07 17:39:54 +02:00
Pierre Tachoire
c9ff59a433 Merge pull request #511 from lightpanda-io/http_chunk_reader_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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
Don't emit incorrect empty chunk
2025-04-07 17:34:53 +02:00
Karl Seguin
ec9a1416a1 Don't emit incorrect empty chunk
When we only have 1 or 2 bytes missing from a chunk (i.e. the tailing \n or
\r\n), don't emit an empty chunk if we have more data available to process.
2025-04-07 22:40:02 +08:00
Pierre Tachoire
dac622fc46 browser: assume no-content type is html 2025-04-07 14:07:55 +02:00
Pierre Tachoire
92e2daf056 Merge pull request #508 from lightpanda-io/readme_iconv_install
add libiconv install direction
2025-04-07 12:09:39 +02:00
Karl Seguin
08e68a1cff add libiconv install direction 2025-04-07 18:01:04 +08:00
Karl Seguin
8f4be9b76f break when child node list fails 2025-04-07 10:40:46 +08:00
Karl Seguin
fab6ec94fa Merge pull request #504 from lightpanda-io/redirect-url
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (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
wpt / 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
browser: update urls after redirection
2025-04-04 16:06:48 +08:00
Pierre Tachoire
5cbcb901f1 browser: fix buffer url usage w/ the arena 2025-04-04 09:53:47 +02:00
Karl Seguin
4d075818f6 Lazily load nodes
Node registry now only tracks the node id (which we need to be consistent) and
the underlying parser.Node. All other data is loaded on-demand (i.e. when we
serialize the node). This allows us to serialize node values as they appear
when they are serialized, as opposed to when they are registered.
2025-04-04 11:24:34 +08:00
Pierre Tachoire
4302be5619 browser: update urls after redirection 2025-04-03 18:27:49 +02:00
Karl Seguin
68d1be3b94 Add children node to CDP Node representation
Add Node writer. Different CDP messages want different child depths. For now,
only support immediate children, but the new writer should make it easy to
support variable.
2025-04-03 21:28:57 +08:00
Karl Seguin
af68b10c5d Better CDP node serialization
Include direct descendant, with hooks for other serialization options.

Don't include parentId if null.
2025-04-03 21:18:18 +08:00
Pierre Tachoire
8b16d0e7ed Merge pull request #495 from lightpanda-io/cdp_node
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (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
wpt / 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
Add CDP Node Registry
2025-04-01 17:25:25 +02:00
katie-lpd
2d5c24d8b5 Update README.md
Typo
2025-04-01 11:54:04 +02:00
Pierre Tachoire
0110ac62bf Merge pull request #490 from karlseguin/cookies
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / 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
Add Cookie support to browser & xhr requests
2025-03-31 14:35:28 +02:00
Pierre Tachoire
5bfa44b1b4 Merge pull request #497 from lightpanda-io/upgrade-jsruntime
ci: add a browser fetch test
2025-03-31 14:17:58 +02:00
Karl Seguin
d21821a0fb add cookie_jar to wpt script 2025-03-31 18:44:09 +08:00
Karl Seguin
84dfde2e51 add cookies to XHR requests 2025-03-31 18:44:09 +08:00
Karl Seguin
22d33fa286 Add cookie support to browser (not XHR yet) requests 2025-03-31 18:44:09 +08:00
Pierre Tachoire
f6f83e2114 upgrade zig-jsruntime 2025-03-31 12:36:23 +02:00
Pierre Tachoire
c6ad734de0 ci: run wpt classic only on PR 2025-03-31 12:35:34 +02:00
Pierre Tachoire
cf015b2ce7 main: exit 1 on memory leak detection 2025-03-31 12:35:33 +02:00
Pierre Tachoire
fbe8086c98 ci: add a browser fetch test 2025-03-31 12:35:29 +02:00
Pierre Tachoire
95cae6e7de Merge pull request #498 from karlseguin/pool_polish
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
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Accommodate zig-js-runtime loop changes
2025-03-31 12:35:09 +02:00
Pierre Tachoire
d12fd78ef0 Merge pull request #499 from karlseguin/http_req_connection_close
On a non websocket upgrade connection, close the connection
2025-03-31 12:25:53 +02:00
Karl Seguin
b2d9f835bf Zig fmt 2025-03-31 15:29:54 +08:00
Karl Seguin
735772f43a On a non websocket upgrade connection, close the connection
Solves slow startup time with chromedp
2025-03-31 15:26:37 +08:00
Karl Seguin
75f66a6cb2 Accommodate zig-js-runtime loop changes 2025-03-31 14:59:40 +08:00
Pierre Tachoire
24d5dfe3c6 Merge pull request #371 from lightpanda-io/ci-wpt-split
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (push) Has been cancelled
wpt / web platform tests json output (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
wpt / perf-fmt (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
ci: split wpt wnd wpt-json jobs
2025-03-28 13:39:32 +01:00
Karl Seguin
be9e953971 Add CDP Node Registry
This expands on the existing CDP node work used in  DOM.search. It introduces
a node registry to track all nodes returned to the client and give lookups to
get a node from a Id or a *parser.node.

Eventually, the goal is to have the Registry emit the DOM.setChildNodes event
whenever necessary, as well as support many of the missing DOM actions.

Added tests to existing search handlers. Reworked search a little bit to avoid
some unnecessary allocations and to hook it into the registry.

The generated Node is currently incomplete. The parentId is missing, the
children are missing. Also, we still need to associate the v8 ObjectId to the
node.

Finally, I moved all action handlers into a nested "domain" folder.
2025-03-28 19:00:29 +08:00
Pierre Tachoire
82e67b7550 Merge pull request #489 from lightpanda-io/microtasks
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
wpt / web platform tests (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / zig build dev (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
run v8 micro tasks
2025-03-27 17:15:50 +01:00
Pierre Tachoire
791549fda8 Merge pull request #494 from karlseguin/insecure_disable_tls_host_verification
Add an `insecure_disable_tls_host_verification` command line option
2025-03-27 15:57:39 +01:00
Pierre Tachoire
c763783d53 upgrade vendor/zig-js-runtime 2025-03-27 15:49:48 +01:00
Pierre Tachoire
e347e7e5fb browser: use loop.resetJS 2025-03-27 15:49:48 +01:00
Pierre Tachoire
3f1d0df7f9 cdp: run microtasks after send inspector 2025-03-27 15:49:48 +01:00
Pierre Tachoire
c6cb6d5eeb Merge pull request #493 from lightpanda-io/upgrade-jsruntime
upgrade vendor/zig-js-runtime
2025-03-27 14:40:28 +01:00
Pierre Tachoire
57025f8173 upgrade vendor/zig-js-runtime 2025-03-27 14:28:00 +01:00
Karl Seguin
3e7f07374c Pass HttpClient options in wpt 2025-03-27 18:18:29 +08:00
Karl Seguin
fba9cb071d zig fmt :| 2025-03-27 18:15:27 +08:00
Karl Seguin
c6538e1038 Add an insecure_disable_tls_host_verification command line option
When set, this disables the host verification of all HTTP requests. Available
for both the fetch and serve mode.

Also introduced an App.Config, for future command line options which need to
be passed more deeply into the code.
2025-03-27 18:02:30 +08:00
Pierre Tachoire
3a1a582013 Merge pull request #482 from karlseguin/http_client
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer-perf (push) Blocked by required conditions
e2e-test / demo-scripts (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
Replace zig-async-io with a custom HTTP client
2025-03-27 08:52:21 +01:00
Karl Seguin
531a484cb0 Fix a few comments
Switch generic http_client error level from warn to err
2025-03-27 08:11:48 +08:00
Pierre Tachoire
16c477229a Merge pull request #491 from lightpanda-io/cla-add-sjors
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (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
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
ci: add Sjors to the cla allow list
2025-03-25 14:49:19 +01:00
Pierre Tachoire
f2565049b8 ci: add Sjors to the cla allow list 2025-03-25 14:40:36 +01:00
Karl Seguin
afdb5d7233 reset read_pos after handshake is established 2025-03-23 20:08:12 +08:00
Karl Seguin
18be1202db Prevent double in-flight recvs
Retry on test timeout for slower machines (i.e. CI build), while also reducing
wait time for faster builds.
2025-03-23 19:05:37 +08:00
Karl Seguin
14cc87e1a5 Use latest tls.zig (with new allocation-free API)
Add more fuzz tests around async tls.
2025-03-23 19:05:37 +08:00
Karl Seguin
2a0d1b0a48 Switch to nonblocking socket
Improve test server handshake performance, allowing for a few more fuzz
iterations without making tests unbearably slow.
2025-03-23 19:05:37 +08:00
Karl Seguin
22aa126b29 Cleaner merge
Switch to non-blocking sockets.

Fix TLS handshake/receive/send ordering
2025-03-23 19:05:35 +08:00
Karl Seguin
feb2046549 add TLS integration test for sync client 2025-03-23 19:01:40 +08:00
Karl Seguin
2f362f2aa2 handle redirects on asynchronous calls 2025-03-23 19:01:40 +08:00
Karl Seguin
de160d9170 Cleanup synchronous connection for tls and non-tls.
Drain response prior to redirect.
2025-03-23 19:01:40 +08:00
Karl Seguin
226c18cb56 handle redirects on synchronous calls 2025-03-23 19:01:40 +08:00
Karl Seguin
314aea4e1e fix double dereference 2025-03-23 19:01:40 +08:00
Karl Seguin
807d3a600c Support transfer-encoding: chunked, fix async+tls integration 2025-03-23 19:01:40 +08:00
Karl Seguin
fa8ea1ef43 use latest tls.zig 2025-03-23 19:01:40 +08:00
Karl Seguin
2017d4785b replace zig-async-io and std.http.Client with a custom HTTP client 2025-03-23 19:01:40 +08:00
Karl Seguin
fd35724aa8 zig 0.14 fmt 2025-03-23 19:01:40 +08:00
Karl Seguin
e1a85d97e3 Zig 0.14 compatibility 2025-03-23 19:01:40 +08:00
Pierre Tachoire
b972c9fe30 Merge pull request #484 from lightpanda-io/telemetry_batch
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (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
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
send telemetry events in batches (up to 20)
2025-03-23 11:21:56 +01:00
Pierre Tachoire
4c68150dec Merge pull request #487 from lightpanda-io/mkdir_p_app_path
Use makePath to create any missing intermediate directories for app dir
2025-03-23 10:48:55 +01:00
Karl Seguin
3d6dd06b99 Generate non-persisted iid if app_path is null 2025-03-22 23:58:57 +08:00
Karl Seguin
81759fa57a Use makePath to create any missing intermediate directories for app dir
https://github.com/lightpanda-io/browser/issues/486
2025-03-22 23:53:46 +08:00
Pierre Tachoire
20160cb071 Merge pull request #485 from lightpanda-io/ubuntu-22-04
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer-perf (push) Blocked by required conditions
e2e-test / demo-scripts (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
ci: use ubuntu 22.04 for x86_64 build
2025-03-22 10:48:57 +01:00
Pierre Tachoire
8931506657 ci: use ubuntu 22.04 for x86_64 build 2025-03-22 10:40:50 +01:00
Karl Seguin
2aee346299 send telemetry events in batches (up to 20) 2025-03-21 22:40:10 +08:00
Pierre Tachoire
f89efd84d3 Merge pull request #481 from lightpanda-io/auto-attach
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer-perf (push) Blocked by required conditions
e2e-test / demo-scripts (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
cdp: implement target.setAutoAttach and target.detachFromTarget
2025-03-21 12:14:01 +01:00
Pierre Tachoire
7607ab2c84 cdp: target: implement detach from target 2025-03-20 09:36:00 +01:00
Pierre Tachoire
fe7f6bee1c cdp: create a cdp state for target_auto_attach 2025-03-20 09:35:59 +01:00
Pierre Tachoire
b43658eb3f cdp: target: add test for #474
Can't attach to just created target
2025-03-20 09:35:59 +01:00
Pierre Tachoire
85caa09e63 Merge pull request #477 from lightpanda-io/ci-v8-version
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (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
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add macos x86_64 bin to nightly build
2025-03-19 17:35:20 +01:00
Pierre Tachoire
c32853bfd6 docker: update zig-v8 version 2025-03-19 17:12:53 +01:00
Pierre Tachoire
e79cd58c8f ci: add macos x86_64 nightly build 2025-03-19 17:12:10 +01:00
Pierre Tachoire
0d291f1a36 ci: upgrade zig v8 version 2025-03-19 17:12:05 +01:00
Pierre Tachoire
24aa8e2a07 Merge pull request #480 from lightpanda-io/zig-0.14
Zig 0.14
2025-03-19 17:06:57 +01:00
Pierre Tachoire
0a0c155292 upgrade vendor after zig 0.14 merge 2025-03-19 16:55:26 +01:00
Pierre Tachoire
55a942aa22 wpt: fix zig-0.14 compat 2025-03-19 16:48:22 +01:00
Karl Seguin
b51499e87b update to latest zig-js-runtime 2025-03-19 16:28:21 +01:00
Karl Seguin
936048d478 upgrade telemetry to zig 0.14 2025-03-19 16:28:21 +01:00
Karl Seguin
bd6497743c zig 0.14 fmt 2025-03-19 16:28:21 +01:00
Karl Seguin
6873d8d445 update tls.zig dep 2025-03-19 16:28:20 +01:00
Karl Seguin
21c9dde858 Zig 0.14 compatibility 2025-03-19 16:28:15 +01:00
Pierre Tachoire
17d3d620ff Merge pull request #478 from lightpanda-io/global_http_client
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer-perf (push) Blocked by required conditions
e2e-test / demo-scripts (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
Share the HTTP client globally
2025-03-19 11:31:37 +01:00
Karl Seguin
705603a088 remove explicit thread stack size.
The real win is having a global http_client, so the thread only needs a pointer.
2025-03-19 16:17:41 +08:00
Karl Seguin
ba8a0179d5 Share the HTTP client globally 2025-03-19 11:09:58 +08:00
Pierre Tachoire
9fe10747ce Merge pull request #476 from karlseguin/implicit_browser_context
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (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
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Implicitly create BrowserContext on createTarget if one doesn't exist
2025-03-18 09:21:50 +01:00
Pierre Tachoire
4a4d9a9377 Merge pull request #446 from karlseguin/telemetry
Add Usage Telemetry
2025-03-18 09:10:31 +01:00
Karl Seguin
2e7342a59c add driver field to navigate telemetry 2025-03-18 10:40:04 +08:00
Karl Seguin
c9bc5be42b add additition navigate fields 2025-03-18 09:56:57 +08:00
Karl Seguin
b75b36dc61 zig fmt 2025-03-18 08:27:47 +08:00
Karl Seguin
1e6a1bd3af store iid in application data directory 2025-03-18 08:27:47 +08:00
Karl Seguin
b0a2087015 fix unit test 2025-03-18 08:27:47 +08:00
Karl Seguin
a5ee34a2db send telemetry synchronously in a background thread 2025-03-18 08:27:47 +08:00
Karl Seguin
a6a8130234 update telemetry URL (but not vendored dependency this time) 2025-03-18 08:27:34 +08:00
Karl Seguin
288761632f Revert "update telemetry URL"
This reverts commit 88850bcdd38026720f03087be8ef7e9869072ac6.
2025-03-18 08:27:34 +08:00
Karl Seguin
25bf4fa738 update telemetry URL 2025-03-18 08:27:34 +08:00
Karl Seguin
3b4de6a405 remove [incorrect] data version 2025-03-18 08:27:32 +08:00
Karl Seguin
75512602c3 Add log to display telemetry state 2025-03-18 08:27:02 +08:00
Karl Seguin
cd33a089d1 flatten events, include aarch + os, remove eid 2025-03-18 08:26:58 +08:00
Karl Seguin
6b83281539 Add navigate telemetry 2025-03-18 08:25:44 +08:00
Karl Seguin
2609671982 don't try (and fail) to get userData after clearing context 2025-03-18 08:02:09 +08:00
Karl Seguin
accf2c0e5e use async-client for telemetry 2025-03-18 08:02:09 +08:00
Karl Seguin
53f6e66c23 Remove plausible, leave a dummy provider for now
Add batching, add install optional id (persisted) and execution id (per run)
2025-03-18 08:02:09 +08:00
Karl Seguin
56ddcc8e29 Initial usage telemetry 2025-03-18 08:02:09 +08:00
Karl Seguin
430779979e Implicitly create BrowserContext on createTarget if one doesn't exist 2025-03-17 20:45:57 +08:00
Pierre Tachoire
671dbcfd55 Merge pull request #470 from lightpanda-io/resove-module
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer-perf (push) Blocked by required conditions
e2e-test / demo-scripts (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
browser: fix module URL resolution
2025-03-17 11:33:59 +01:00
Pierre Tachoire
087a7b5f3c browser: use *const Page with fetchModule 2025-03-17 09:58:31 +01:00
Pierre Tachoire
229844d399 browser: use *const Script with evalScript 2025-03-17 09:51:01 +01:00
Pierre Tachoire
36081653b0 Merge pull request #472 from lightpanda-io/linux_aarch64
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (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
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
ci: use ubuntu 24.04
2025-03-15 10:40:09 +01:00
Pierre Tachoire
9811c5d577 ci: use ubuntu 24.04 2025-03-15 10:24:34 +01:00
Pierre Tachoire
4394186dc3 Merge pull request #469 from lightpanda-io/linux_aarch64
Linux aarch64 build
2025-03-15 10:17:42 +01:00
Pierre Tachoire
725b48d8aa ci: fix install params for linux 2025-03-15 10:01:46 +01:00
Pierre Tachoire
3fd8347943 browser: fix module URL resolution 2025-03-14 19:02:33 +01:00
Pierre Tachoire
5e7c26c34b dockerfile: add ARCH parameter 2025-03-14 17:27:17 +01:00
Pierre Tachoire
d7019264a2 docker: upgrade ubuntu 2025-03-14 14:51:05 +01:00
Pierre Tachoire
ade9fa5d0e ci: add linux aarch64 to the nightly build 2025-03-14 14:38:05 +01:00
Pierre Tachoire
f84c4393b9 ci: upgrade zig-v8 version 2025-03-14 14:37:38 +01:00
Pierre Tachoire
48d01c0ab5 Merge pull request #465 from lightpanda-io/inspector-cache-debug
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer-perf (push) Blocked by required conditions
e2e-test / demo-scripts (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
don't generate debug js file on release
2025-03-14 11:52:30 +01:00
Pierre Tachoire
aca01d81d6 cdp: use .zig-cache to save js script debug files 2025-03-14 11:41:21 +01:00
Pierre Tachoire
6a0b154d67 cdp: dump runtime js only in debug mode 2025-03-14 11:41:20 +01:00
Pierre Tachoire
7ce69987d5 Merge pull request #463 from karlseguin/page_arena
Optimize memory usage
2025-03-14 10:17:41 +01:00
Karl Seguin
3fe28d5441 Optimize memory usage
The two bigger changes here are:

1- The http_client has been moved from the Session to the Browser, allowing
   its connection pool to be re-used across multiple sessions

2- The browser now has a page_arena which is used for all page-level allocation
   and which can be re-used between pages (currently retains 1MB of memory).
   Previously, pages uses an arena that was tied to the lifetime of the page,
   thus it could not be re-used.

Using the Bench allocator for zig-js-runtime, allocated bytes went from
1347037879 to 834932438 (in a RUNS=1000 of puppeteer demo).

Various other changes to try to simplify the API and remove the possibility
of invalid states. For example, session.newPage() now includes the logic for
page.start() so that there should now never be a page that wasn't started.
2025-03-12 13:38:22 +08:00
Pierre Tachoire
43f42f9ca0 Merge pull request #461 from lightpanda-io/ci-playwright
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (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
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
ci: add e2e test w/ playwright connection
2025-03-11 10:11:08 +01:00
Pierre Tachoire
3e288f1fcf Merge pull request #462 from lightpanda-io/upgrade-jsruntime
upgrade vendor/zig-js-runtime
2025-03-11 10:10:06 +01:00
Pierre Tachoire
8ccd75fdfb upgrade vendor/zig-js-runtime 2025-03-11 09:53:33 +01:00
Pierre Tachoire
fd6aa6e54e ci: add e2e test w/ playwright connection 2025-03-11 09:52:11 +01:00
Pierre Tachoire
4802a2ce82 Merge pull request #460 from karlseguin/playwright
Remove CDP FrameId
2025-03-11 08:41:39 +01:00
Karl Seguin
e3409a27e7 fix test 2025-03-11 10:51:40 +08:00
Karl Seguin
5182edce6f Remove CDP FrameId
I don't know if FrameId is related to an <iframe>, and whether each Page has
1 implicit "frame". But, playwright seems to treat frameId and targetId as
interchangeable, and chrome seems to agree (at leas to some degree); chrome will
return a targetId and reuse that value for the frameId.

So the simplest solution is just to remove our concept of a frameId and use
targetId exclusively. This doesn't seem to cause any issues with puppeteer.
2025-03-11 10:37:43 +08:00
Pierre Tachoire
763d8d025e Merge pull request #453 from lightpanda-io/loop-reset
Some checks failed
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer-perf (push) Blocked by required conditions
e2e-test / demo-scripts (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
Reset loop event after page stop.
2025-03-10 16:07:34 +01:00
Pierre Tachoire
a3045c9808 ci: run demo's puppeteer scripts 2025-03-10 15:59:46 +01:00
Pierre Tachoire
6b78b011b7 upgrade zig-jsruntime 2025-03-10 15:59:46 +01:00
Pierre Tachoire
bd7b84e136 loop: reset the loop after page end 2025-03-10 15:59:46 +01:00
Pierre Tachoire
2a9bab3f13 Merge pull request #450 from lightpanda-io/cdp-playwright
cdp: improve playwright support
2025-03-10 15:56:41 +01:00
Pierre Tachoire
6ca1e6c6dd cdp: let the inspector return the response
When a command is forwarded to the inspector, it handles directly the
reponse to the message.
2025-03-10 14:57:10 +01:00
Pierre Tachoire
f3a1a6a191 cdp: add a Page.getFrameTree unit test 2025-03-10 14:57:10 +01:00
Pierre Tachoire
675932c65b cdp: improve playwright support
The getTargetInfo result must return a `targetInfo` key.

Here is an example returned by Chrome:
```json
{
  "id": 16,
  "result": {
    "targetInfo": {
      "targetId": "d93a1bbc-f906-4bbb-bb4d-a2285234b091",
      "type": "browser",
      "title": "",
      "url": "",
      "attached": true,
      "canAccessOpener": false
    }
  }
}
```
2025-03-10 14:57:05 +01:00
Pierre Tachoire
708abb0e30 Merge pull request #459 from lightpanda-io/browser_context
Make CDP server more authoritative with respect to IDs
2025-03-10 14:49:53 +01:00
Karl Seguin
9de84aee2e Don't send CDP result when message is forward to inspector.
Rely on inspector to send the result, otherwise we'll send 2 responses to the
same message (one ourselves and one from the inspector), which Playwright does
not like.
2025-03-10 14:34:32 +01:00
Karl Seguin
adb8779d00 allow Target.getTargetInfo to be called without parameters 2025-03-10 14:34:32 +01:00
Karl Seguin
fbb0e675f5 send attach events before result 2025-03-10 14:34:32 +01:00
Karl Seguin
a3e2b5246e Make CDP server more authoritative with respect to IDs
The TL;DR is that this commit enforces the use of correct IDs, introduces a
BrowserContext, and adds some CDP tests.

These are the ids we need to be aware of when talking about CDP:
- id
- browserContextId
- targetId
- sessionId
- loaderId
- frameId

The `id` is the only one that _should_ originate from the driver. It's attached
to most messages and it's how we maintain a request -> response flow: when
the server responds to a specific message, it echo's back the id from the
requested message. (As opposed to out-of-band events sent from the server which
won't have an `id`). When I say "id" from this point forward, I mean every id
except for this req->res id.

Every other id is created by the browser.

Prior to this commit, we didn't really check incoming ids from the driver. If
the driver said "attachToTarget" and included a targetId, we just assumed that
this was the current targetId. This was aided by the fact that we only used
hard-coded IDS. If _we_ only "create" a frameId of "FRAME-1", then it's tempting
to think the driver will only ever send a frameId of "FRAME-1".

The issue with this approach is that _if_ the browser and driver fall out of sync
and there's only ever 1 browserContextId, 1 sessionId and 1 frameId, it's not
impossible to imagine cases where we behave on the thing.

Imagine this flow:
- Driver asks for a new BrowserContext
- Browser says OK, your browserContextId is 1
- Driver, for whatever reason, says close browserContextId 2
- Browser says, OK, but it doesn't check the id and just closes the only
  BrowserContext it knows about (which is 1)

By both re-using the same hard-coded ids, and not verifying that the ids sent
from the client correspond to the correct ids, any issues are going to be hard
to debug.

Currently LOADER_ID and FRAEM_ID are still hard-coded. Baby steps.
2025-03-10 14:34:32 +01:00
Pierre Tachoire
ccacac0597 Merge pull request #458 from karlseguin/serialized_writes
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
Serialize socket writes + consider client pending completions when sh…
2025-03-10 10:24:57 +01:00
Karl Seguin
ca230aa230 Serialize socket writes + consider client pending completions when shutting down
Previously, we could have multiple in-flight messages from the server to a
single client. This isn't safe and can lead to message interleaving. While
write / send are atomic, they are only atomic for the N bytes which they write,
which may not be the entire buffer. Consider this writeAll function:

```
pub fn writeAll(socket: socket_t, bytes: []const u8) !void {
    var index: usize = 0;
    while (index < bytes.len) {
        index += try posix.write(socket, bytes[index..]);
    }
}
```

If we're trying to send "abc123", this could take anywhere from 1 to 6 calls
to posix.write (it would take 6 calls, for example, if every call to
posix.write only wrote a single byte). Now if you're trying to write other data
to this same socket at the same time, messages _will_ get interleaved.

In order for this to work, the client now has a send_queue (doubly linked list).
When one message is sent, it sends the next.

In addition to the above change, the Client is now self-contained with respect
to its lifetime. This is necessary so that completions which come in AFTER our
concept of its lifetime ends, can still be processed. I think all types that
receive completions need to follow this model. This relies on the fact that
kqueue (which I know for a fact) and io_uring (which people seem to imply) handle
socket shutdown properly. It's still a bit messy because of timeout and not
wanting to wait until timeout to accept new connections, but needing to wait
until timeout to cleanup the client.

The self-contained nature of Client makes it difficult to test as a generic. I
removed Client(T). Tests now use real sockets. Some tests had to be removed
because they're too difficult to test over a real connection :(
2025-03-07 20:29:57 +08:00
Pierre Tachoire
7b775d2ad7 Merge pull request #452 from lightpanda-io/katie-lpd-patch-1
Update README.md
2025-03-04 17:16:34 +01:00
Pierre Tachoire
c5397bfbe2 Merge pull request #448 from karlseguin/set_cookie
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / puppeteer (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add Set-Cookie parsing
2025-03-04 13:20:33 +01:00
Karl Seguin
9fec6ebc66 fix typo, improve comment, add 1 test case 2025-03-04 19:46:36 +08:00
Pierre Tachoire
6bc38c5782 Merge pull request #455 from lightpanda-io/upgrade-zig-azync-io
upgrade vendor/zig-async-io
2025-03-04 11:37:30 +01:00
Pierre Tachoire
7f9d585d7f upgrade vendor/zig-async-io 2025-03-04 11:29:17 +01:00
Pierre Tachoire
0b14d36c95 Merge pull request #454 from lightpanda-io/upgrade-zig-jsruntime
upgrade vendor/zig-js-runtime
2025-03-04 11:07:26 +01:00
Pierre Tachoire
e22ca2d082 upgrade vendor/zig-js-runtime 2025-03-03 15:37:43 +01:00
katie-lpd
52a70cb7f5 Update README.md
A really important visual change in the readme :)
2025-03-01 19:43:28 +01:00
Karl Seguin
a00d1d068a Cookie with SameSite=None is only valid when Secure 2025-02-27 16:47:39 +08:00
Pierre Tachoire
6ae4ed9fc3 Merge pull request #449 from karlseguin/longer_timeout
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / puppeteer (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
allow longer timeouts (u8 -> u16)
2025-02-27 09:11:25 +01:00
Karl Seguin
6f5028612a add cookie jar 2025-02-27 16:09:10 +08:00
Karl Seguin
c31c12d31a add test for Storage shed, use map.getOrPut 2025-02-27 11:57:46 +08:00
Karl Seguin
28008d835e allow longer timeouts (u8 -> u16) 2025-02-27 11:00:37 +08:00
Pierre Tachoire
08e99a32cb Merge pull request #445 from karlseguin/capture_git_commit
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
Make the the short git SHA available within the program
2025-02-26 14:10:24 +01:00
Karl Seguin
68fc87bc01 Add Set-Cookie parsing 2025-02-26 21:00:43 +08:00
Karl Seguin
d0ba06c44b Add git_commit to build and build-dev target
Add "version" command to cli.
2025-02-26 20:44:44 +08:00
Karl Seguin
d501cbf765 Make the the short git SHA available within the program 2025-02-26 20:44:44 +08:00
Pierre Tachoire
488c7e6c27 Merge pull request #447 from lightpanda-io/mem-regression
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
ci: increase the max memory value to detect regression
2025-02-26 11:38:54 +01:00
Pierre Tachoire
155559c2c4 ci: increase the max memory value to detect regression 2025-02-26 10:55:19 +01:00
Pierre Tachoire
a22e1bc5e5 Merge pull request #442 from karlseguin/cli_commands
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
Add explicit commands to binary
2025-02-25 09:17:45 +01:00
Karl Seguin
9519d3f7ce use an arena for the args 2025-02-22 20:25:01 +08:00
Pierre Tachoire
3f23e07c02 Merge pull request #443 from karlseguin/logging
Some checks failed
e2e-test / zig build release (push) Has been cancelled
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / puppeteer (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add a structured logger
2025-02-22 12:28:12 +01:00
Pierre Tachoire
6c75177edc Merge pull request #444 from karlseguin/id
Add an id generator
2025-02-22 12:25:54 +01:00
Karl Seguin
85df280447 When explicit mode (serve/fetch/help) isn't given, infer it from the options 2025-02-22 13:54:05 +08:00
Karl Seguin
734cf243f6 update workflow to launch lightpanda in serve mode 2025-02-22 12:40:47 +08:00
Karl Seguin
d8f7817eeb Add explicit commands to binary
./lightpanda serve --host ...
./lightpanda fetch https://...

Makes it easier to communicate / document which command has which options.

Internally added a "usage" command for displaying the usage - removing the need
for error.NoError :|
2025-02-22 12:40:47 +08:00
Karl Seguin
94b6b2636a Add an id generator
Create UUID v4.

Create prefixed ids. To support more of the CDP protocol, we need to remove the
hard-coded IDs (session, browser context, frame, loader, ...) and be able to
dynamically create them, i.e. creating a new BrowserContextId when
Target.createBrowserContext is called.

var frame_id = id.Incremental(u16, "FRM"){};
frame_id.next() == "FRM-1"
frame_id.next() == "FRM-2"

Generation is allocation-free (the returned string is only valid until the
next call to next()). This is not thread safe, each CDP instance will have its
own generator (for each id it needs to generate).

The generated IDs are different than what Chrome uses, i.e.
BROWSERSESSIONID597D9875C664CAC0. I looked at various drivers and none have
any expectations beyond a string. Shorter IDs will be more efficient. Also, the
ID can cheeply be converted to and from an integer, allowing for lookups via
AutoHashMap(u16) instead of StringHashMap.
2025-02-22 09:11:40 +08:00
Karl Seguin
1036f7580f Add a structured logger
In debug mode, it has a more user-friendly output:

level | the log messge | ms since last message | key=value key=value

In release mode, it logs using logfmt, which is supported by most log
ingestion frameworks.

Not being used anywhere right now, keeping this PR small with no impact on
existing code.
2025-02-22 09:10:40 +08:00
Pierre Tachoire
908febb363 Merge pull request #441 from karlseguin/cdp_tests
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
Turn CDP into a generic so that mocks can be injected for testing
2025-02-21 17:49:47 +01:00
Pierre Tachoire
aefd091b44 Merge pull request #440 from karlseguin/managed_completions
Ensure completions are executed on the currently connected client
2025-02-21 17:39:22 +01:00
Karl Seguin
99fb82e244 Turn CDP into a generic so that mocks can be injected for testing
ADD CDP testing helpers (mock Browser, Session, Page and Client). These are
placeholders until tests are added which use them.

Added a couple CDP tests.
2025-02-21 13:17:35 +08:00
Karl Seguin
756d6620cc Ensure completions are executed on the currently connected client
For the time being, given that we only allow 1 client at a time, I took a
shortcut to implement this. The server has an incrementing "current_client_id"
which is part of every completion. On completion callback, we just check if
its client_id is still equal to the server's current_client_id.
2025-02-21 09:35:51 +08:00
Pierre Tachoire
09505dba09 Merge pull request #436 from lightpanda-io/ci-unittest
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
ci: add unittest execution
2025-02-20 17:45:41 +01:00
Pierre Tachoire
9401eff297 ci: add unittest execution 2025-02-20 17:10:10 +01:00
Pierre Tachoire
adbec3d272 Merge pull request #439 from karlseguin/dont_share_timeout_completion
Don't share or reuse timeout_completion
2025-02-20 17:09:44 +01:00
Karl Seguin
e301ba0cdb Don't share or reuse timeout_completion
Results in undefined behavior when a client disconnects and another reconnects
while the timeout is being monitored:

https://github.com/lightpanda-io/browser/pull/436#issuecomment-2670455216
2025-02-20 23:56:55 +08:00
Pierre Tachoire
b12eef218a Merge pull request #422 from karlseguin/cdp_struct
Refactor CDP
2025-02-20 15:26:37 +01:00
Karl Seguin
bc4560877a zig fmt 2025-02-20 22:08:56 +08:00
Karl Seguin
521a740d3a Merge branch 'main' into cdp_struct 2025-02-20 22:08:37 +08:00
Pierre Tachoire
be12b724cc Merge pull request #438 from karlseguin/xhr_state_as_enum
Use an enum for XHR's state.
2025-02-20 14:57:37 +01:00
Pierre Tachoire
073873a3e9 Merge pull request #437 from karlseguin/make_zig_path
Use $(ZIG) variable when building netsurf
2025-02-20 14:56:55 +01:00
Pierre Tachoire
fcdcb50b8b Merge pull request #426 from karlseguin/c_allocator
In release mode, switch from page_allocator to c_allocator
2025-02-20 14:37:54 +01:00
Karl Seguin
61a7848fd9 Use an enum for XHR's state. 2025-02-20 14:06:38 +08:00
Karl Seguin
6d6b840cf6 Use $(ZIG) variable when building netsurf 2025-02-20 08:42:45 +08:00
Karl Seguin
4dbba103d4 In release mode, switch from page_allocator to c_allocator 2025-02-20 08:09:53 +08:00
Pierre Tachoire
a2932f05f4 Merge pull request #435 from karlseguin/server_tests
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
Fix server hang on client disconnect
2025-02-19 17:45:42 +01:00
Pierre Tachoire
5d4efb7692 Merge pull request #434 from lightpanda-io/chore/readme
Chore: update readme images
2025-02-19 16:41:15 +01:00
Karl Seguin
39a9efb73b Fix server hang on client disconnect
https://github.com/lightpanda-io/browser/issues/425

Add a few integration tests for the TCP server which are fast enough to be run
as part of the unit tests (one of the new tests covers the above issue).
2025-02-19 15:01:12 +08:00
Nicolas Rigaudiere
5037bd07d5 chore: update readme images 2025-02-18 15:43:49 +01:00
Pierre Tachoire
73a2fa3f9c Merge pull request #428 from lightpanda-io/ci-rgression
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / puppeteer (push) Blocked by required conditions
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
ci: add puppeteer regression test
2025-02-18 15:07:17 +01:00
Pierre Tachoire
79387f469b Merge pull request #433 from lightpanda-io/adjust-readme
readme: adjust image width
2025-02-18 13:57:51 +01:00
Pierre Tachoire
f986cfecff readme: adjust image width 2025-02-18 13:51:10 +01:00
Pierre Tachoire
4d51a9123b Merge pull request #432 from lightpanda-io/adjust-readme
readme: move status up
2025-02-18 13:43:24 +01:00
Pierre Tachoire
7602f15544 readme: move status up 2025-02-18 13:41:45 +01:00
Pierre Tachoire
3180ba7de9 Merge pull request #431 from lightpanda-io/adjust-readme
readme: update benchmark image
2025-02-18 11:55:56 +01:00
Pierre Tachoire
3e01cf19b0 readme: add benchmark details 2025-02-18 11:55:21 +01:00
Pierre Tachoire
14eebfe39e readme: update benchmark image 2025-02-18 11:55:21 +01:00
Pierre Tachoire
9176599b29 Merge pull request #430 from lightpanda-io/adjust-readme
readme: fix badges
2025-02-18 11:37:52 +01:00
Pierre Tachoire
d6575faa9f readme: fix badges 2025-02-18 11:37:08 +01:00
Pierre Tachoire
24c5bf9ff4 Merge pull request #429 from lightpanda-io/adjust-readme
readme: update download instructions + improve CDP example
2025-02-18 11:35:22 +01:00
Pierre Tachoire
cdcc5e106f readme: use curl to download binary 2025-02-18 11:32:53 +01:00
Pierre Tachoire
1a8cc2d019 readme: adjust text 2025-02-18 11:32:30 +01:00
Pierre Tachoire
27e907491b readme: remove text duplication 2025-02-18 11:25:04 +01:00
Pierre Tachoire
0a1e6623c8 readme: allow examples copy/paste 2025-02-18 11:20:56 +01:00
Pierre Tachoire
689dddd11a readme: allow copy/paste install instruction 2025-02-18 11:19:02 +01:00
Pierre Tachoire
f8d01e1596 readme: update exemple t odump links 2025-02-18 11:15:06 +01:00
Pierre Tachoire
cd429f5935 readme: fix binary name 2025-02-18 11:06:51 +01:00
Pierre Tachoire
03355f6a4a readme: remove useless badges 2025-02-18 11:01:52 +01:00
Pierre Tachoire
dc1d593019 ci: adjust memory regression max values 2025-02-18 10:57:36 +01:00
Pierre Tachoire
9894cceeaa ci: extract end-to-end test on its own file 2025-02-18 10:52:08 +01:00
Pierre Tachoire
bcedbc845e ci: add puppeteer regression test 2025-02-17 16:39:15 +01:00
Karl Seguin
f508288ce3 Fix segfault when multiple inflight Send completions fail 2025-02-17 18:43:41 +08:00
Karl Seguin
18080cef9f fix test 2025-02-17 12:14:11 +08:00
Karl Seguin
c4eeef2a86 On CDP process error, let client decide how to close
Fixes issue where CDP closes the client, but client still registers a recv
operation.
2025-02-17 12:05:25 +08:00
Karl Seguin
b60a91f53c fix memory leak 2025-02-17 11:45:19 +08:00
Karl Seguin
b1c3de6518 zig fmt 2025-02-13 17:32:01 +08:00
Karl Seguin
a43a6a299c Merge branch 'main' into cdp_struct 2025-02-13 17:30:15 +08:00
Pierre Tachoire
d8fae5bc41 Merge pull request #408 from karlseguin/websocket_server
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
zig-test / demo-puppeteer (push) Has been cancelled
Make TCP server websocket-aware
2025-02-13 09:04:23 +01:00
Karl Seguin
fa9b6f58e5 trying to fix submodule version 2025-02-13 09:42:26 +08:00
Karl Seguin
89ff1411e9 Fix memory leak on invalid websocket continuation frames 2025-02-13 09:34:25 +08:00
Karl Seguin
701e8277d6 support continuation frames 2025-02-13 08:51:21 +08:00
Karl Seguin
4a11f80c45 Make websocket client reader stateful
Move more logic into the reader. Avoid copying partial messages in
cases where we know that the buffer is large enough.

This is mostly groundwork for trying to add support for continuation
frames.
2025-02-13 08:51:21 +08:00
Karl Seguin
f1b275d5d0 Increase fuzz count. Add test for [too] large HTTP requests 2025-02-13 08:51:21 +08:00
Karl Seguin
68e0ffc95c "fix" test compilation 2025-02-13 08:51:21 +08:00
Karl Seguin
0753eb7691 zig fmt 2025-02-13 08:51:21 +08:00
Karl Seguin
92afcd174d remove websocket.zig dependency from build 2025-02-13 08:51:21 +08:00
Karl Seguin
94be7a0e79 Make TCP server websocket-aware
Adding HTTP & websocket awareness to the TCP server.

HTTP server handles `GET /json/version` and websocket upgrade requests.

Conceptually, websocket handling is the same code as before, but receiving
data will parse the websocket frames and writing data will wrap it in
a websocket frame.

The previous `Ctx` was split into a `Server` and a `Client`. This was
largely done to make it easy to write unit tests, since the `Client` is
a generic, all its dependencies (i.e. the server) can be mocked out. This
also makes it a bit nicer to know if there is or isn't a client (via the
server's client optional).

Added a MemoryPool for the Send object (I thought that was a nice touch!)

Removed MacOS hack on accept/conn completion usage.

Known issues:
- When framing an outgoing message, the entire message has to be duped. This
is no worse than how it was before, but it should be possible to eliminate
this in the future. Probably not part of this PR.

- Websocket parsing will reject continuation frames. I don't know of a single
client that will send a fragmented message (websocket has its own
message fragmentation), but we should probably still support this just in
case.

- I don't think the receive, timeout and close completions can safely be
re-used like we're doing. I believe they need to be associated with a specific
client socket.

- A new connection creates a new browser session. I think this is right (??),
but for the very first, we're throwing out a perfectly usable session. I'm
thinking this might be a change to how Browser/Sessions work.

- zig build test won't compile. This branch reproduces the issue with none
of these changes:
https://github.com/karlseguin/browser/tree/broken_test_build

(or, as a diff to main):
https://github.com/lightpanda-io/browser/compare/main...karlseguin:broken_test_build
2025-02-13 08:51:19 +08:00
Pierre Tachoire
0814daf99d Merge pull request #421 from lightpanda-io/upgrade-tigerbeetle
Some checks are pending
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig build release (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
zig-test / demo-puppeteer (push) Blocked by required conditions
upgrade tigerbeetle
2025-02-12 14:46:35 +01:00
Pierre Tachoire
b2e3419bff upgrade tigerbeetle 2025-02-12 14:37:39 +01:00
Karl Seguin
1846d0bc21 drats, zig fmt again 2025-02-12 18:32:33 +08:00
Karl Seguin
d282055e10 Merge branch 'main' into cdp_struct 2025-02-12 17:56:47 +08:00
Karl Seguin
6ab64d155b Refactor CDP
CDP is now an struct which contains its own state a browser and a session.

When a client connection is made and successfully upgrades, the client creates
the CDP instance. There is now a cleaner separation betwen Server, Client and
CDP.

Removed a number of allocations, especially when writing results/events from
CDP to the client. Improved input message parsing. Tried to remove some usage
of undefined.
2025-02-12 16:47:37 +08:00
Pierre Tachoire
6ba3e57f5f Merge pull request #404 from lightpanda-io/cdp-documentUpdated
Some checks are pending
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig build release (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
zig-test / demo-puppeteer (push) Blocked by required conditions
cdp: dispatch a DOM.documentUpdated event
2025-02-11 14:38:39 +01:00
Karl Seguin
14fe4f65e1 support continuation frames 2025-02-11 11:16:39 +08:00
Karl Seguin
bdb70444d6 Make websocket client reader stateful
Move more logic into the reader. Avoid copying partial messages in
cases where we know that the buffer is large enough.

This is mostly groundwork for trying to add support for continuation
frames.
2025-02-11 11:16:39 +08:00
Karl Seguin
4d9cc55a87 Increase fuzz count. Add test for [too] large HTTP requests 2025-02-11 11:16:39 +08:00
Karl Seguin
f41c1cbfd0 "fix" test compilation 2025-02-11 11:16:39 +08:00
Karl Seguin
72eaab68be zig fmt 2025-02-11 11:16:39 +08:00
Karl Seguin
733c6b4c17 remove websocket.zig dependency from build 2025-02-11 11:16:39 +08:00
Karl Seguin
c0c0694fcc Make TCP server websocket-aware
Adding HTTP & websocket awareness to the TCP server.

HTTP server handles `GET /json/version` and websocket upgrade requests.

Conceptually, websocket handling is the same code as before, but receiving
data will parse the websocket frames and writing data will wrap it in
a websocket frame.

The previous `Ctx` was split into a `Server` and a `Client`. This was
largely done to make it easy to write unit tests, since the `Client` is
a generic, all its dependencies (i.e. the server) can be mocked out. This
also makes it a bit nicer to know if there is or isn't a client (via the
server's client optional).

Added a MemoryPool for the Send object (I thought that was a nice touch!)

Removed MacOS hack on accept/conn completion usage.

Known issues:
- When framing an outgoing message, the entire message has to be duped. This
is no worse than how it was before, but it should be possible to eliminate
this in the future. Probably not part of this PR.

- Websocket parsing will reject continuation frames. I don't know of a single
client that will send a fragmented message (websocket has its own
message fragmentation), but we should probably still support this just in
case.

- I don't think the receive, timeout and close completions can safely be
re-used like we're doing. I believe they need to be associated with a specific
client socket.

- A new connection creates a new browser session. I think this is right (??),
but for the very first, we're throwing out a perfectly usable session. I'm
thinking this might be a change to how Browser/Sessions work.

- zig build test won't compile. This branch reproduces the issue with none
of these changes:
https://github.com/karlseguin/browser/tree/broken_test_build

(or, as a diff to main):
https://github.com/lightpanda-io/browser/compare/main...karlseguin:broken_test_build
2025-02-11 11:16:39 +08:00
Pierre Tachoire
055530c8c6 cdp: send dom node children 2025-02-10 12:19:35 +01:00
Pierre Tachoire
fb3b38aec7 cdp: implement getSearchResults and discardSearchResults 2025-02-10 09:31:10 +01:00
Pierre Tachoire
4e4a8f1bab cdp: implement DOM.performSearch 2025-02-10 09:31:09 +01:00
Pierre Tachoire
39b3786776 cdp: ctx state has init and deinit now 2025-02-10 09:31:09 +01:00
Pierre Tachoire
8b22313ca1 netsurf: return empty string on null for node name 2025-02-10 09:31:09 +01:00
Pierre Tachoire
402f72cfa8 cdp: adjust page deinit 2025-02-10 09:31:08 +01:00
Pierre Tachoire
e7dcb8a605 cdp: introduce current page
avoid page struct copy
2025-02-10 09:31:08 +01:00
Pierre Tachoire
8f8a1fda85 cdp: implement DOM.getDocument 2025-02-10 09:31:08 +01:00
Pierre Tachoire
26be25c3d5 cdp: dispatch a DOM.documentUpdated event 2025-02-10 09:31:04 +01:00
Pierre Tachoire
50b53b00e0 Merge pull request #414 from karlseguin/url_query
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
zig-test / demo-puppeteer (push) Has been cancelled
Improve memory and performance of url.Query
2025-02-10 09:30:37 +01:00
Karl Seguin
94531cb3d0 Improve memory and performance of url.Query
1 - Use getOrPut to avoid making 2 map lookups where possible.

2 - Use an arena allocator for Values, which makes memory management simpler.

3 - Because of #2, we no longer need to allocate key or values which don't need
    to be unescaped. The downside is that the input string has to outlive the
    query.Values (but I think this is currently always the case)

4 - Optimize unescape logic & allocations

5 - Improve test coverage
2025-02-10 16:11:44 +08:00
Pierre Tachoire
842760255b Merge pull request #413 from karlseguin/mime
Improve performance & compliance of MIME parsing
2025-02-10 08:59:00 +01:00
Pierre Tachoire
c78b582d71 Merge pull request #409 from karlseguin/unittest_build
Add a new unittest build step
2025-02-10 08:45:24 +01:00
Karl Seguin
4ab02fab1c Fix build.
zig build test can pass, but zig build run won't even compile. // TODO: fix.
2025-02-10 11:18:16 +08:00
Karl Seguin
6863f3227f Improve performance & compliance of MIME parsing
Common cases, text/html, text/xml and text/plain parse about 2x faster. Other
cases are about 30% faster.

Support quoted attributes, i.e. charset="utf-8" & valid escape sequences. This
potentially requires allocation, thus Mime.parse now takes an allocator.

Stricter validation around type/subtype based on RFC.

More tests.

Replace Mime.eql with isHTML(). Equality is complicated and was previously
incorrect (it was case sensitive, it should not be). Since we currently only
use isHTML-like behavior, built a (faster) method specifically for that.
2025-02-10 11:07:55 +08:00
Karl Seguin
d01d43eccb Remove setup/teardown functionality. YAGNI 2025-02-09 13:16:27 +08:00
Karl Seguin
2aa5f4fc82 small iterator tweak 2025-02-09 11:04:21 +08:00
Karl Seguin
3af0531111 zig fmt + add U32Iterator tests 2025-02-09 11:04:21 +08:00
Karl Seguin
6e58b98b3d Startup local HTTP server for testing
Change loader tests to use local HTTP server.

Add missing test scripts (i.e. storage) to unittest runs.
2025-02-09 11:04:21 +08:00
Karl Seguin
62805cdf1d add license to file 2025-02-09 11:04:21 +08:00
Karl Seguin
4229b1d2a4 Report memory leaks when using std.testing.allocator
Fix leaks in storage bottle
2025-02-09 11:04:21 +08:00
Karl Seguin
2c4661a250 Add a new unittest build step
Preserves all existing behavior (i.e. make test and zig build test are not
changed in any way).

The new 'unittest' only runs unit tests and is fast to build. It takes ~1.7 to
build unittest, vs ~11.09 to build test. This is really the main goal, and
hopefully any unit test which are (a) fast and (b) don't impact build times
will be run here.

The test runner is based on:
https://gist.github.com/karlseguin/c6bea5b35e4e8d26af6f81c22cb5d76b

It allow filtering, i.e. `make unittest F="parse query dup"`.

'unittest' does memory leak detection when tests use std.testing.allocator.

Fixed a memory leak in url/query which was detected/reported with by the new
'unittest'.

In order to avoid having 3 src/test_xyx.zig files, I merged the existing
test_runner.zig and run_tests.zig into a single main_tests.zig. (this change
is superfluous, but I thought it was cleaner this way. Happy to revert this).
2025-02-09 11:04:21 +08:00
Pierre Tachoire
0c1a486ed9 Merge pull request #411 from karlseguin/reader_tweak
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
zig-test / demo-puppeteer (push) Has been cancelled
Minor Reader tweaks
2025-02-08 11:33:49 +01:00
Karl Seguin
688cb55c2b Minor Reader tweaks
1- Remove `parser.trim`, it was only being used in 1 place. All other places
   are using `std.mem.trim(u8, X, &std.ascii.whitespace)`, so i updated MIME to
   use this as well

2- Use slightly more meaningful field name, i => pos, s = data

3- Leverage std.mem.indexOfScalarPos which can be more efficient for longer
   inputs (since it leverages SIMD)
2025-02-08 15:57:32 +08:00
Francis Bouvier
1594f148f8 Merge pull request #399 from karlseguin/generate
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
zig-test / demo-puppeteer (push) Has been cancelled
Tweak generate.Tuple and generate.Union
2025-02-06 08:53:52 +01:00
Karl Seguin
fafd8c4af1 Improve format and re-add docstrings
Implements RP suggestions.
2025-02-06 09:57:04 +08:00
Karl Seguin
3d66758507 zig fmt 2025-02-01 15:38:08 +08:00
Karl Seguin
fc0ec860b0 Tweak generate.Tuple and generate.Union
Leverage comptime fields to give generated Tuple a default value, allowing
TupleT and Tuple to be merged.

Only call generate.Tuple on the final output. This eliminates redundant
deduplication, and results in a simpler API (nested types just need to expose
a natural Zig tuple).

generate.Union leverages the new Tuple and removes unused features.
2025-02-01 14:53:00 +08:00
Pierre Tachoire
00d332cd16 Merge pull request #396 from karlseguin/xmlserializer
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
zig-test / demo-puppeteer (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
Add HTML encoding to text node and HTML attribute values
2025-01-31 12:22:52 +01:00
Pierre Tachoire
4c8c0f8738 Merge pull request #394 from lightpanda-io/xmlserializer
implement XMLSerializer
2025-01-31 09:09:47 +01:00
Karl Seguin
54978132bb Add HTML encoding to text node and HTML attribute values 2025-01-31 16:01:32 +08:00
Pierre Tachoire
018abe0188 dom: implement outerHTML 2025-01-30 16:09:47 +01:00
Pierre Tachoire
b186497fb0 implement XMLSerializer 2025-01-30 16:09:47 +01:00
Pierre Tachoire
27f9963ccb Merge pull request #391 from lightpanda-io/cdp-ctx-sessionid
Some checks are pending
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig build release (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
zig-test / demo-puppeteer (push) Blocked by required conditions
cdp: use an enum for SessionID
2025-01-30 14:02:47 +01:00
Pierre Tachoire
a4e3f03bf5 Merge pull request #393 from karlseguin/submodules_over_https
Use https:// instead of git@ for submodules
2025-01-30 14:01:43 +01:00
Pierre Tachoire
27a6be4ce0 Merge pull request #392 from karlseguin/readme_typo
Fix small install typo in readme
2025-01-30 08:59:39 +01:00
Karl Seguin
76a2520e56 Use https:// instead of git@ for submodules
Allows easily building the project, following the steps in readme, without
a github account or having some special git configuration.
2025-01-30 11:56:37 +08:00
Karl Seguin
0a472681af Fix small install typo in readme 2025-01-30 11:50:12 +08:00
Pierre Tachoire
6d530691f3 cdp: use an enum for SessionID 2025-01-29 18:38:05 +01:00
Pierre Tachoire
a74c9e8481 Merge pull request #389 from lightpanda-io/cdp-empty-params
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
zig-test / demo-puppeteer (push) Has been cancelled
chromedp: msg missing params or result
2025-01-28 17:11:03 +01:00
Pierre Tachoire
8aac26a331 cdp: check parameter's type on sendEvent
Disallow void type.
2025-01-28 16:01:47 +01:00
Pierre Tachoire
fc59a0f6ab cdp: send empty param instead of void
Sending void parameters generated unmarshal errors with chromedp client.
Empty struct is required.
2025-01-28 15:46:13 +01:00
Pierre Tachoire
3fb16774b7 Merge pull request #356 from lightpanda-io/location
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
zig-test / demo-puppeteer (push) Has been cancelled
dom: first draft for location
2025-01-27 13:16:05 +01:00
Pierre Tachoire
7b35bb4c0f dom: improve location impl 2025-01-27 12:33:06 +01:00
Pierre Tachoire
318e2bd1c6 dom: expose document.location 2025-01-27 12:33:05 +01:00
Pierre Tachoire
09ba4bcf43 upgrade libdom 2025-01-27 12:33:04 +01:00
Pierre Tachoire
0c89fa7b1e Merge pull request #383 from lightpanda-io/katie-lpd-patch-1
Update README.md
2025-01-27 10:26:42 +01:00
katie-lpd
7eedb3320d Update README.md 2025-01-27 09:59:30 +01:00
Pierre Tachoire
cfac75ea49 Merge pull request #380 from eltociear/patch-1
docs: update README.md
2025-01-26 20:59:27 +01:00
Ikko Eltociear Ashimine
f00a6c396f docs: update README.md
dependancies -> dependencies
dependancy -> dependency
2025-01-27 03:20:00 +09:00
Pierre Tachoire
e74a9711ca Merge pull request #378 from spidy0x0/patch-1
fix typo
2025-01-25 10:34:41 +01:00
Pierre Tachoire
636d3cdf90 Merge pull request #377 from arilotter/patch-1
fix typo in readme
2025-01-25 10:34:16 +01:00
spidy0x0
71966affa1 fix typo 2025-01-24 19:47:12 +00:00
Ari Lotter
bf4dc195ec fix typo in readme 2025-01-24 12:53:19 -05:00
Pierre Tachoire
dccca17e09 Merge pull request #376 from lightpanda-io/katie-lpd-patch-1
Update README.md
2025-01-24 12:03:09 +01:00
Pierre Tachoire
5381a4354c add badges 2025-01-24 12:02:42 +01:00
katie-lpd
c70425fbf7 Update README.md 2025-01-24 11:53:05 +01:00
Pierre Tachoire
341f5725a4 netsurf: implement location setter/getter 2025-01-23 15:22:03 +01:00
Pierre Tachoire
d7069df80d dom: first draft for location 2025-01-23 11:51:05 +01:00
Pierre Tachoire
579714a60b Merge pull request #374 from lightpanda-io/reuseport-unixsocket
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
zig-test / demo-puppeteer (push) Has been cancelled
server: REUSEPORT is not allowed on unix socket
2025-01-22 16:23:58 +01:00
Pierre Tachoire
bbdf63635a server: REUSEPORT is not allowed on unix socket
see 5b0af621c3
2025-01-22 16:08:22 +01:00
Pierre Tachoire
fd7db18221 ci: split wpt and wpt-json jobs 2025-01-22 13:59:14 +01:00
Pierre Tachoire
482ed8d958 Merge pull request #370 from lightpanda-io/kernel-version
upgrade vendor/zig-js-runtime
2025-01-22 13:58:22 +01:00
Pierre Tachoire
673e16878d ci: run tests on vendor changes 2025-01-22 13:48:52 +01:00
Pierre Tachoire
e11ceab029 upgrade vendor/zig-js-runtime 2025-01-22 13:46:54 +01:00
Pierre Tachoire
7fe719f43c Merge pull request #361 from lightpanda-io/docker-updage-zig-v8
docker: update zig v8
2025-01-17 12:36:53 +01:00
Pierre Tachoire
3fd3ac1de1 docker: update zig v8 2025-01-17 12:30:28 +01:00
Pierre Tachoire
0e90a675af Merge pull request #357 from katie-lpd/patch-2
Update README.md
2025-01-16 14:09:53 +01:00
katie-lpd
ee861c1f91 Update README.md 2025-01-16 14:06:00 +01:00
Pierre Tachoire
40c9355088 Merge pull request #355 from lightpanda-io/history
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
zig-test / demo-puppeteer (push) Has been cancelled
dom: history placeholder
2025-01-15 11:55:50 +01:00
Pierre Tachoire
8f1557254a typo fix 2025-01-15 11:45:30 +01:00
Pierre Tachoire
11d28b0bc3 dom: add placeholder for history interface 2025-01-15 11:45:30 +01:00
Pierre Tachoire
974cf780c0 dom: clean history file 2025-01-14 15:04:40 +01:00
Pierre Tachoire
73bb14e4a9 Merge pull request #285 from lightpanda-io/cdp-cdpcli
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
zig-test / demo-puppeteer (push) Has been cancelled
cdp: cdpcli compatibility
2025-01-13 18:16:40 +01:00
Pierre Tachoire
daf4236023 runtime: fix sessionid 2025-01-13 18:08:09 +01:00
Pierre Tachoire
4c9a24c64e start inspector when the js env starts 2025-01-13 10:53:38 +01:00
Pierre Tachoire
c149f65158 cdp: remove event dispateched by inspector 2025-01-13 10:53:36 +01:00
Pierre Tachoire
c5688c1bd3 cdp: display last message on cdp error 2025-01-13 10:53:35 +01:00
Pierre Tachoire
b276a15786 cdp: add target.detachFromTarget noop 2025-01-13 10:53:33 +01:00
Pierre Tachoire
2fed239ece browser: split page start from page navigate 2025-01-13 10:53:29 +01:00
Pierre Tachoire
8e2cb36597 cdp: fix some id inconsitency accross runtime messages 2025-01-13 10:49:48 +01:00
Pierre Tachoire
bcaace1c91 cdp: use identifiable hard coded ids 2025-01-13 10:47:51 +01:00
Pierre Tachoire
d664d07141 cdp: dispatch executionContextCreated on Runtime.enable 2025-01-13 10:47:42 +01:00
Pierre Tachoire
cb8b80c856 Merge pull request #345 from lightpanda-io/modules
browser: support for modules
2025-01-13 10:45:31 +01:00
Pierre Tachoire
d777d77b06 ci: update sig-v8 version 2025-01-13 10:36:52 +01:00
Pierre Tachoire
43678f8dc0 upgrade zig-js-runtime 2025-01-13 10:36:34 +01:00
Pierre Tachoire
5811577824 Merge pull request #354 from lightpanda-io/navigator-fix
Some checks are pending
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig build release (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
zig-test / demo-puppeteer (push) Blocked by required conditions
navigator: remove useless import
2025-01-13 09:37:14 +01:00
Pierre Tachoire
1587122efa navigator: remove useless import 2025-01-10 17:42:20 +01:00
Pierre Tachoire
48e7c8ad0f browser: implement fetch module 2025-01-10 16:48:45 +01:00
Pierre Tachoire
766f9798f6 browser: load module 2025-01-09 11:48:39 +01:00
Pierre Tachoire
680d634725 update zig-js-runtime 2025-01-09 11:48:39 +01:00
Pierre Tachoire
7ac945bf88 browser: refacto script 2025-01-09 11:48:34 +01:00
Pierre Tachoire
188d7a8558 Merge pull request #352 from utay/fix-shell-main
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
zig-test / demo-puppeteer (push) Has been cancelled
Fix shell main
2025-01-08 17:15:18 +01:00
Pierre Tachoire
ee561b0d1e Merge pull request #353 from lightpanda-io/cdp-enable-security
Cdp enable security
2025-01-08 16:55:31 +01:00
Pierre Tachoire
82bbe78e95 cdp: return correct result on Runtime 2025-01-08 16:46:05 +01:00
Pierre Tachoire
c761cd059b cdp: log errors on sendMessageToTarget 2025-01-08 16:17:29 +01:00
Pierre Tachoire
03e87155ca cdp: add security.enable 2025-01-08 16:17:20 +01:00
Yannick Utard
ea39cc52b1 Fix shell main 2025-01-08 15:28:19 +01:00
Pierre Tachoire
90fb90b186 Merge pull request #351 from lightpanda-io/ignore-blank
Some checks are pending
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig build release (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
zig-test / demo-puppeteer (push) Blocked by required conditions
browser: ignore about:blank navigation
2025-01-08 13:54:00 +01:00
Pierre Tachoire
5f8327eaf7 browser: ignore about:blank navigation 2025-01-08 13:44:41 +01:00
Pierre Tachoire
07869f3c48 Merge pull request #350 from lightpanda-io/cdp-enable
Cdp enable
2025-01-08 13:42:42 +01:00
Pierre Tachoire
8377eb02a5 cdp: add CSS.enable 2025-01-08 12:01:18 +01:00
Pierre Tachoire
3738e8eb44 cdp: add DOM.enable 2025-01-08 12:01:18 +01:00
Pierre Tachoire
4b000e44b3 cdp: add Inspector.enable 2025-01-08 12:01:18 +01:00
Pierre Tachoire
d6021d1702 Merge pull request #348 from lightpanda-io/dom-navigator
Dom navigator
2025-01-08 11:47:44 +01:00
Pierre Tachoire
b391eafc38 wpt: update tests 2025-01-08 11:38:11 +01:00
Pierre Tachoire
2af4545e10 dom: implement more navigator API 2025-01-08 11:03:26 +01:00
Pierre Tachoire
b96644d893 dom: implement navigatorLanguage 2025-01-08 11:03:26 +01:00
Pierre Tachoire
3cb77c0a32 dom: implement navigatorID 2025-01-08 11:03:25 +01:00
Pierre Tachoire
b3f7fb7be3 dom: implement navigator.userAgent 2025-01-08 11:03:11 +01:00
Pierre Tachoire
9fb51a1f29 Merge pull request #346 from lightpanda-io/target-created
cdp: add TargetCreated event on createTarget message
2025-01-08 10:10:18 +01:00
Pierre Tachoire
d78e8a725d cdp: remove useless parameter 2025-01-08 09:57:28 +01:00
Pierre Tachoire
7829bdbc95 Merge pull request #347 from lightpanda-io/send-message-to-target
cdp: add Target.sendMessageToTarget support
2025-01-07 16:21:41 +01:00
Pierre Tachoire
90ba6deba2 cdp: add Target.sendMessageToTarget support
see https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-sendMessageToTarget
2025-01-07 16:00:44 +01:00
Pierre Tachoire
5fc763a738 cdp: add TargetCreated event on createTarget message 2025-01-07 10:34:47 +01:00
Pierre Tachoire
84614e903c Merge pull request #344 from lightpanda-io/test-e2e
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
zig-test / demo-puppeteer (push) Has been cancelled
Add end to end tests
2025-01-06 10:13:51 +01:00
Pierre Tachoire
c95d739347 ci: add test end to end
using puppeteer test from https://github.com/lightpanda-io/demo
2024-12-31 12:01:27 +01:00
Pierre Tachoire
c55849cef5 Merge pull request #343 from lightpanda-io/upgrade-zig-js
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
upgrade zig-js-runtime deps
2024-12-31 10:37:22 +01:00
Pierre Tachoire
88adb09417 upgrade zig-js-runtime deps 2024-12-31 10:09:48 +01:00
Pierre Tachoire
cb356ffca9 Merge pull request #341 from lightpanda-io/docker
fix docker port
2024-12-26 10:02:54 +01:00
Pierre Tachoire
2ba3f252ee fix docker port 2024-12-26 10:00:59 +01:00
Pierre Tachoire
ebe2c8e3dd Merge pull request #332 from lightpanda-io/verbose-option
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
add --verbose option
2024-12-17 09:41:58 +01:00
Francis Bouvier
ca2d63edcf Merge pull request #330 from lightpanda-io/contributing
add a CONTRIBUTING file
2024-12-16 15:54:17 +01:00
Francis Bouvier
489a1a1c37 Merge pull request #338 from lightpanda-io/README
README: be more precise on current status
2024-12-16 13:43:45 +01:00
Francis Bouvier
5cad13eea3 README: be more precise on current status
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-12-16 13:35:24 +01:00
Pierre Tachoire
a3e09e015c Merge pull request #329 from lightpanda-io/readme-getting-started
readme: add a quick start section
2024-12-16 09:34:10 +01:00
Pierre Tachoire
22a6accac1 readme: add a quick start section 2024-12-13 17:45:30 +01:00
Pierre Tachoire
277c97a959 Merge pull request #331 from lightpanda-io/continue-on-error
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
browser: don't stop processing page on error status code
2024-12-13 17:41:34 +01:00
Pierre Tachoire
c08bc3239a Merge pull request #334 from lightpanda-io/patch-1
Update README.md
2024-12-13 15:05:34 +01:00
katie-lpd
f798c7e0fb Update README.md 2024-12-13 14:45:02 +01:00
Pierre Tachoire
193780a88f cla: add katie-lpd to the allow list
Some checks are pending
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig build release (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
2024-12-13 14:33:57 +01:00
Pierre Tachoire
ab4973ab6c add --verbose option 2024-12-13 12:29:05 +01:00
Pierre Tachoire
f9da815e8f browser: don't stop processing page on error status code 2024-12-13 11:56:18 +01:00
Pierre Tachoire
59e187d59a add a CONTRIBUTING file 2024-12-13 11:28:14 +01:00
Francis Bouvier
3b018e2a6d Merge pull request #325 from lightpanda-io/await_promise
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
cdp: runtime, replace `"awaitPromise":true` only if present
2024-12-09 11:19:45 +01:00
Francis Bouvier
d4939c8260 Merge pull request #322 from lightpanda-io/cdp_msg_nullable_params
Some checks are pending
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig build release (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
cdp: handle nullable Type for params
2024-12-08 19:08:27 +01:00
Francis Bouvier
485352594c Merge pull request #324 from lightpanda-io/json_parse_unknown_field
cdp: do not throw error on json parse for unknown fields
2024-12-08 19:05:11 +01:00
Francis Bouvier
bcdcfee467 Merge pull request #323 from lightpanda-io/cdp_msg_size
Cdp msg size
2024-12-08 18:58:14 +01:00
Francis Bouvier
0217e3fcae cdp: runtime, replace "awaitPromise":true only if present
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-12-08 15:33:32 +01:00
Francis Bouvier
6e7d6421d5 cdp: do not throw error on json parse for unknown fields
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-12-08 15:27:12 +01:00
Francis Bouvier
913d3af938 cdp: increase msg size 16KB -> 256KB
And move header size encoding from 2 bytes -> 2 bytes

Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-12-04 23:06:55 +01:00
Francis Bouvier
53dd0a5e4c cdp: handle nullable Type for params
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-12-04 22:39:54 +01:00
Francis Bouvier
4b8c3cb188 Merge pull request #321 from lightpanda-io/README
Some checks failed
wpt / web platform tests (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
README: more updates
2024-12-04 17:16:01 +01:00
Francis Bouvier
c9ca170d57 README: more updates
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-12-04 16:18:15 +01:00
Francis Bouvier
c6090dbc16 Update README
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-12-04 16:03:01 +01:00
Francis Bouvier
c787b6a1a5 Merge pull request #312 from lightpanda-io/fetch-polyfill
Fetch polyfill
2024-12-04 15:57:45 +01:00
Francis Bouvier
766aa0f60a Merge pull request #320 from lightpanda-io/http_json_version
websockets: add addr server info in Stream
2024-12-04 15:56:06 +01:00
Pierre Tachoire
fbe8835626 polyfill: use @embedfile to embed polyfill 2024-12-04 14:26:51 +01:00
Pierre Tachoire
4e2b35b585 tests: remove useless test file 2024-12-04 14:26:21 +01:00
Francis Bouvier
8ef79e348c websockets: add addr server info in Stream
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-12-04 12:07:52 +01:00
Pierre Tachoire
47eef392d1 add missing license header 2024-12-03 14:34:04 +01:00
Francis Bouvier
b846541ff6 Merge pull request #311 from lightpanda-io/msg_size_encode
Some checks are pending
wpt / web platform tests (push) Waiting to run
wpt / perf-fmt (push) Blocked by required conditions
zig-test / zig build dev (push) Waiting to run
zig-test / zig build release (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
Msg size encode
2024-12-03 14:33:33 +01:00
Pierre Tachoire
adfffd2b08 polyfill: fetch: disable Arraybuffer usage 2024-12-02 18:00:32 +01:00
Pierre Tachoire
4138c6fe95 polyfill: first draft to polyfill fetch api 2024-12-02 18:00:32 +01:00
Francis Bouvier
3088c7a632 Merge pull request #318 from lightpanda-io/README
Update README
2024-11-29 18:41:10 +01:00
Francis Bouvier
21e7638ae1 Update README
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-29 18:40:26 +01:00
Francis Bouvier
ca0d90dbcf Merge pull request #310 from lightpanda-io/websockets
websocket: first implementation
2024-11-29 18:22:05 +01:00
Pierre Tachoire
8870045b0f Merge pull request #317 from lightpanda-io/upgrade-zig-async-io
upgrade zig-async-io
2024-11-29 15:22:13 +01:00
Pierre Tachoire
cbc8b2edf9 upgrade zig-async-io 2024-11-29 15:12:13 +01:00
Francis Bouvier
8f297b83c1 msg: rename MsgBuffer -> Buffer
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-29 15:07:52 +01:00
Francis Bouvier
b800d0eeb8 msg: fix len for msg.Buffer and encode msg size as binary header
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-29 15:07:52 +01:00
Francis Bouvier
d95462073a websockets: fix port default in help
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-29 15:06:22 +01:00
Francis Bouvier
95ac92b343 server: fix cancel
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-29 14:59:54 +01:00
Pierre Tachoire
4cfb317af6 Merge pull request #316 from lightpanda-io/innerText
dom: implement innerText
2024-11-28 17:55:14 +01:00
Pierre Tachoire
487aaffa94 dom: implement innerText 2024-11-28 17:47:41 +01:00
Pierre Tachoire
24fd7c7286 Merge pull request #314 from lightpanda-io/fix-xhr
xhr: fix invalid response with empty type
2024-11-28 16:34:30 +01:00
Pierre Tachoire
50e62d44ff xhr: fix invalid response with empty type 2024-11-28 16:29:09 +01:00
Francis Bouvier
760c082757 cli: wording mode -> opts
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-27 21:24:09 +01:00
Francis Bouvier
8449d5ab22 websocket: use Unix socket for internal server
And add an option for TCP only server

Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-27 21:24:09 +01:00
Francis Bouvier
27b50c46c3 Update websokets dep
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-27 21:24:09 +01:00
Francis Bouvier
325ecedf0b websocket: first implementation
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-27 21:24:09 +01:00
Francis Bouvier
8f7a8c0ee1 Merge pull request #309 from lightpanda-io/update_deps
update zig-js-runtime
2024-11-26 12:59:19 +01:00
Francis Bouvier
7dbb55da06 update zig-js-runtime
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-26 12:58:45 +01:00
Pierre Tachoire
4b90b70534 Merge pull request #308 from lightpanda-io/licensing
add licensing file to be more explicit w/ licenses
2024-11-26 12:52:16 +01:00
Pierre Tachoire
d6f1843ef3 add licensing file to be more explicit w/ licenses 2024-11-26 12:30:50 +01:00
Pierre Tachoire
5cf176927a Merge pull request #303 from lcoffe-botify/fix-makefile-download-zig
fix Makefile download-zig: url changed
2024-11-26 11:03:31 +01:00
Pierre Tachoire
eae21a034e Merge pull request #306 from lightpanda-io/cla
contrib: add CLA signature process
2024-11-26 10:31:17 +01:00
Pierre Tachoire
06855cdfa3 Merge pull request #307 from lightpanda-io/clean-ci
ci: remove useless token
2024-11-26 09:52:05 +01:00
Pierre Tachoire
45c7af9769 ci: remove useless token
The repos are public now, we don't need the token anymore to fetch.
2024-11-26 09:37:40 +01:00
Pierre Tachoire
f9ddd5c368 contrib: add CLA signature process 2024-11-26 09:35:14 +01:00
Pierre Tachoire
6bf4dc887e Merge pull request #305 from lightpanda-io/docker-ca-cert
docker: keep ca certs
2024-11-25 11:51:10 +01:00
Pierre Tachoire
4cc31bb41f docker: keep ca certs 2024-11-25 09:03:52 +01:00
Lucien Coffe
88e32505a3 fix Makefile download-zig: url changed 2024-11-22 18:50:11 +01:00
Francis Bouvier
5b5d28f7c1 Merge pull request #302 from lightpanda-io/zig-async-io
Use zig-async-io for xhr requests
2024-11-21 16:52:36 +01:00
Francis Bouvier
1a2fd9a584 Update dependencies
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-21 16:46:09 +01:00
Francis Bouvier
de286dd78e async: use zig-async-io
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-21 16:42:48 +01:00
Pierre Tachoire
70752027f1 async: remove @This from SigleThreaded 2024-11-19 15:55:26 +01:00
Pierre Tachoire
395eb3e8ad async: add missing tests execution 2024-11-19 15:54:04 +01:00
Pierre Tachoire
e1137274fb async: remove dead code 2024-11-19 15:51:36 +01:00
Francis Bouvier
bc716ef0ad Merge pull request #299 from lightpanda-io/cli_refacto
cli: code refacto
2024-11-19 15:31:19 +01:00
Pierre Tachoire
d2d2e851b0 async: fix assync call pop error 2024-11-18 17:39:38 +01:00
Pierre Tachoire
9149b60136 async: remove dead code 2024-11-18 17:39:38 +01:00
Pierre Tachoire
18ab0c8199 cdp: replace tick by run_for_ns 2024-11-18 17:39:38 +01:00
Pierre Tachoire
7fed1f3015 async: remove pseudo-async http client 2024-11-18 17:39:37 +01:00
Pierre Tachoire
6809bb5393 async: adapt async cli 2024-11-18 17:39:37 +01:00
Pierre Tachoire
fadf3f609a http: add full async client 2024-11-18 17:39:37 +01:00
Pierre Tachoire
8d2d089803 Merge pull request #301 from lightpanda-io/better-log
Better log
2024-11-18 14:39:40 +01:00
Pierre Tachoire
f6ad95c647 improve event's log result 2024-11-18 13:13:52 +01:00
Pierre Tachoire
0788e22dd5 typo fix 2024-11-18 13:13:23 +01:00
Francis Bouvier
de260693dc Merge pull request #296 from lightpanda-io/memory_leak
cdp: fix memory leak in msg parsing of the JSON
2024-11-15 02:17:01 +01:00
Francis Bouvier
f60fcbec04 Merge pull request #286 from lightpanda-io/cdp-refacto-input
cdp: refacto message JSON read
2024-11-15 01:03:53 +01:00
Francis Bouvier
4f99407462 Merge pull request #288 from lightpanda-io/cdp-create-target
cdp: browserContextId is optional in Target.createTarget
2024-11-15 00:53:22 +01:00
Pierre Tachoire
38813a20a7 Merge pull request #298 from lightpanda-io/docker-warning
docker: use absolute path with WORKDIR
2024-11-14 08:56:34 +01:00
Pierre Tachoire
2cd1e927f7 docker: use absolute path with WORKDIR
remove following the warning
```
 1 warning found (use docker --debug to expand):
 - WorkdirRelativePath: Relative workdir "browser" can have unexpected results if the base image changes (line 48)
 ```
2024-11-14 08:46:01 +01:00
Pierre Tachoire
5094942560 cdp: add msg tests into zig build test 2024-11-12 12:56:30 +01:00
Pierre Tachoire
82c37fc71b cdp: refacto message JSON read 2024-11-12 12:56:29 +01:00
Pierre Tachoire
8ba911c8dd cdp: return provided browser context id if any 2024-11-12 10:56:06 +01:00
Francis Bouvier
ac77453139 cli: code refacto
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-10 13:53:07 +01:00
Francis Bouvier
8a25545cac memory: use a GPA in Debug mode and a page allocator in Release
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-09 13:34:15 +01:00
Francis Bouvier
ed3a464843 cdp: fix memory leak in msg parsing of the JSON
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-09 03:25:42 +01:00
Francis Bouvier
1854074f64 Merge pull request #293 from lightpanda-io/cdp-contextid
cdp: use a u32 for context id
2024-11-07 15:49:40 +01:00
Francis Bouvier
ec5de2fce0 Merge pull request #287 from lightpanda-io/cdp-attach-to-target
cdp: add Target.attachToTarget noop
2024-11-07 15:49:15 +01:00
Francis Bouvier
3af34d11ca Merge pull request #291 from lightpanda-io/multi_build
Multi build
2024-11-06 18:17:24 +01:00
Francis Bouvier
eed7b7186d Merge pull request #284 from lightpanda-io/server-sync-deinit
server: ensure Send is always deinit in callback
2024-11-06 18:17:10 +01:00
Francis Bouvier
d5e7ebdc63 Merge pull request #295 from lightpanda-io/fix_cdp_full_async
Fix cdp full async
2024-11-06 18:14:43 +01:00
Francis Bouvier
3ecfa6aca8 Dockerfile: add install-libiconv
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-06 18:10:08 +01:00
Francis Bouvier
625c1741c6 Update zig-js-runtime (tigerbeetle)
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-06 18:07:43 +01:00
Francis Bouvier
f6f5ec5eb3 server: add cancel current recv before accepting new connection
Only on Linux. On MacOS cancel is not supported for now and
we do not have any problem with the current recv operation
on a closed socket.

Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-06 18:07:43 +01:00
Francis Bouvier
c74feb9c3a server: add log on I/O errors
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-05 17:16:39 +01:00
Pierre Tachoire
0d76f80223 cdp: use a u32 for context id 2024-11-04 10:08:36 +01:00
Pierre Tachoire
1e64513c16 Merge pull request #292 from lightpanda-io/tcp_nodelay
server: set TCP.NODELAY on linux to avoid latency issues
2024-11-04 10:04:25 +01:00
Francis Bouvier
64779acf32 Merge pull request #278 from lightpanda-io/cdp_full_async
Cdp full async
2024-11-01 18:14:21 +01:00
Francis Bouvier
c3a3ac19f4 server: set TCP.NODELAY on linux to avoid latency issues
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-11-01 17:54:49 +01:00
Francis Bouvier
b9bae3f66d build: update gitignore
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-30 13:41:48 +01:00
Francis Bouvier
2a2486cbe0 build: fix clean-libiconv
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-30 13:41:34 +01:00
Pierre Tachoire
0813d99b44 Merge pull request #290 from lightpanda-io/dockerfile
dockerfile: adjust binary name after merge
2024-10-30 10:42:15 +01:00
Pierre Tachoire
491e89d102 dockerfile: adjust binary name after merge 2024-10-30 10:40:59 +01:00
Francis Bouvier
f01558251c Merge pull request #277 from lightpanda-io/merge_bin
Merge get and server binaires
2024-10-29 22:27:09 +01:00
Francis Bouvier
8665d0420b Merge pull request #282 from lightpanda-io/docker-build
add a Dockerfile to build the project
2024-10-29 22:20:49 +01:00
Francis Bouvier
cf0636ca63 Update src/main.zig usage
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2024-10-29 22:19:44 +01:00
Francis Bouvier
46d0aa6f9e Remove all references to the name 'browsercore'
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-29 22:16:56 +01:00
Francis Bouvier
b9e2be2052 build: support multi os/arch conf for netsurf
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-29 20:06:29 +01:00
Pierre Tachoire
b3054d68bf cdp: browserContextId is optional in Target.createTarget
https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-createTarget
2024-10-29 10:37:23 +01:00
Pierre Tachoire
60adf0a9c3 cdp: add Target.attachToTarget noop 2024-10-29 10:34:36 +01:00
Francis Bouvier
be5d7022cc build: support multi os/arch conf for libiconv
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-28 21:08:46 +01:00
Francis Bouvier
d1951b286c build: support multi os/arch conf for mimalloc
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-28 16:06:41 +01:00
Pierre Tachoire
dcdef2f640 server: ensure Send is always deinit in callback 2024-10-25 09:51:37 +02:00
Pierre Tachoire
7afe74310f add a Dockerfile to build the project 2024-10-23 15:22:12 +02:00
Pierre Tachoire
826f82610e Merge pull request #280 from lightpanda-io/cdpdump-close-dir
cdp: close dir in dumpFile
2024-10-23 10:15:31 +02:00
Pierre Tachoire
5d7796b95d cdp: close dir in dumpFile
and avoid error.ProcessFdQuotaExceeded error
2024-10-23 10:02:34 +02:00
Pierre Tachoire
b3ac313cc7 Merge pull request #279 from lightpanda-io/ci-ubuntu
ci: use ubuntu 22.04 for nightly build
2024-10-22 15:43:39 +02:00
Pierre Tachoire
b281ba7754 ci: use zig-v8 0.1.9 2024-10-22 15:03:01 +02:00
Pierre Tachoire
10994b202b ci: use ubuntu latest for all expect nightly build 2024-10-22 14:27:47 +02:00
Pierre Tachoire
2aeac1bdeb ci: force ubuntu 22.04 for nightly build
To ensure a better compatibility.
2024-10-22 14:27:10 +02:00
Francis Bouvier
8508c21080 cdp: remove send sync
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-21 18:29:10 +02:00
Francis Bouvier
20dd140c31 cdp: send I/O next read before executing current cmd
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-21 18:21:43 +02:00
Francis Bouvier
486c19079a Merge get and server binaires
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-18 16:06:23 +02:00
Pierre Tachoire
f30501ca3c Merge pull request #276 from lightpanda-io/compare-position
node: implement node.compareDocumentPosition
2024-10-17 15:06:47 +02:00
Pierre Tachoire
e67e6e267b Merge pull request #275 from lightpanda-io/fake-css-properties
html: implement empty style property
2024-10-17 15:06:40 +02:00
Pierre Tachoire
8dc757ddf3 node: implement getRootNode 2024-10-17 14:44:34 +02:00
Pierre Tachoire
b64f7d013d node: implement node.compareDocumentPosition 2024-10-17 14:44:33 +02:00
Francis Bouvier
62ec936f1e Merge pull request #215 from lightpanda-io/cdp_basic
CDP basic
2024-10-17 10:52:09 +02:00
Francis Bouvier
8d83dfad45 ci: force ubuntu version (24.04)
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-17 10:24:00 +02:00
Pierre Tachoire
e450072f45 ci: add zig v8 version into the cache key 2024-10-16 21:00:44 +02:00
Francis Bouvier
7f08d08a78 Update zig-v8 again
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-16 17:54:41 +02:00
Francis Bouvier
b0634cd871 Adapt wpt and shell to zig-js-runtime changes
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-16 15:21:03 +02:00
Francis Bouvier
462485bfcb Update zig-v8 and zig-js-runtime deps
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-16 14:56:04 +02:00
Francis Bouvier
2311765289 Remove some dead code
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-16 14:53:50 +02:00
Francis Bouvier
7bc7da5499 browser: back on createPage returning a Page (pointer)
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-16 14:53:11 +02:00
Pierre Tachoire
b712a4771e html: implement empty style property 2024-10-16 10:22:23 +02:00
Francis Bouvier
8e05f09fc8 server, cdp: improve logging
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-15 22:57:56 +02:00
Francis Bouvier
84c49fbe34 cdp: ensure there is an ID on each request
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-15 17:28:18 +02:00
Francis Bouvier
7750956c7b msg: Add a more complex test case with 2 multipart messages combined
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-15 16:07:46 +02:00
Francis Bouvier
ea9af210f9 Remove heap allocation for Session
And adapt to similar changes on zig-js-runtime for Env

Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-15 15:52:48 +02:00
Francis Bouvier
efca71510a browser: put back VM is an arg for browser init
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-12 10:41:59 +02:00
Francis Bouvier
cbf6348055 server: panic if sendInspector without an inspector
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-12 10:38:53 +02:00
Francis Bouvier
ec680593b0 msg: set a hard limit max size
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-11 18:13:20 +02:00
Francis Bouvier
fd6c25daaa msg: improve comments on reallocation
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-11 18:05:04 +02:00
Francis Bouvier
4b495f213f cdp: add comment on hard coded ID for page.createIsolatedWorld
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 15:21:09 +02:00
Francis Bouvier
7ad03fb548 cdp: fix a comment on page.navigate
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 15:18:55 +02:00
Francis Bouvier
17c641845e msg: return error if input does not have "size:"
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 15:13:06 +02:00
Francis Bouvier
e53b9d984b browser: add comment for auxData param in page.navigate
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 15:10:30 +02:00
Francis Bouvier
28593d93ff browser: panic if callInspector without Inspector
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 12:47:14 +02:00
Francis Bouvier
fa4920bd94 browser: rename setInspector -> initInspector
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 12:45:28 +02:00
Francis Bouvier
eaf5c6f86f cdp: ensure method action is present
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 12:42:20 +02:00
Francis Bouvier
0d89b98bad cdp: ensure token is a string when needed in parser
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 12:35:56 +02:00
Francis Bouvier
bf56345e48 msg: comments typos
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 12:19:35 +02:00
Francis Bouvier
2bc58bebce server: rename public -> jsruntime
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 12:11:43 +02:00
Francis Bouvier
c564702eac server: formatting
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 12:10:54 +02:00
Francis Bouvier
9400dd799e Add cli options for server (host, port, timeout)
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 12:06:39 +02:00
Francis Bouvier
ff0bbc3f96 server: simplify Send I/O
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 01:21:24 +02:00
Francis Bouvier
15414f5ee4 server: remove unused sendLater
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 01:00:12 +02:00
Francis Bouvier
f9b097794f Simplify browser session.setInspector
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 00:58:13 +02:00
Francis Bouvier
a2f65eb540 server: simplify onInspector methods
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 00:56:15 +02:00
Francis Bouvier
cea38a10e9 server: rename buf in read_buf
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 00:56:11 +02:00
Francis Bouvier
c8a91d4cf6 server: merge Cmd and Accept in Ctx
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-09 00:55:29 +02:00
Francis Bouvier
b0ff325125 server: move to TCP conn
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-08 23:44:47 +02:00
Francis Bouvier
c35c09db60 server: timeout mechanism
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-08 23:40:50 +02:00
Francis Bouvier
49adb61146 server: handle close and re-open connection
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-08 16:22:24 +02:00
Francis Bouvier
76a9034668 server: newSession on disposeBrowserContext
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-07 21:14:55 +02:00
Francis Bouvier
4c225e515d server: let the caller of sendSync free the string
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-07 16:04:29 +02:00
Francis Bouvier
9c913b2e6c Move loop outside Browser
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-07 15:57:16 +02:00
Francis Bouvier
5ab1d2a8a5 Add License in new cdp files
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-01 18:02:21 +02:00
Francis Bouvier
2f3a581859 Add TODOs and comments
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-01 17:48:54 +02:00
Francis Bouvier
8bdd2a14e8 Add Target.disposeBrowserContext
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-01 17:13:47 +02:00
Francis Bouvier
1675f69582 Add Target.closeTarget
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-01 17:13:29 +02:00
Francis Bouvier
94d2d28806 Redirect Runtime domain to JS engine Inspector
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-10-01 17:12:08 +02:00
Pierre Tachoire
82a5e50056 Merge pull request #274 from lightpanda-io/upgrade-libdom
upgrade libdom
2024-09-25 11:53:28 +02:00
Pierre Tachoire
46062e185a upgrade libdom 2024-09-25 11:47:34 +02:00
Pierre Tachoire
6929141210 Merge pull request #273 from lightpanda-io/nodelist-iterator
nodelist: remove debug log
2024-09-25 09:51:00 +02:00
Pierre Tachoire
cce36c5fbd nodelist: remove debug log 2024-09-25 09:50:31 +02:00
Pierre Tachoire
2518287326 Merge pull request #272 from lightpanda-io/nodelist-iterator
nodelist: implement iterators
2024-09-25 09:42:59 +02:00
Pierre Tachoire
aefab86501 nodelist: implement iterators 2024-09-25 09:37:14 +02:00
Pierre Tachoire
30679d18ee Merge pull request #271 from lightpanda-io/currentscript
implement DOM document.currentscript
2024-09-24 10:14:21 +02:00
Pierre Tachoire
95c0ff6f39 dom: implement currentScript 2024-09-24 10:01:13 +02:00
Pierre Tachoire
4d6f59ecb8 upgrade libdom 2024-09-24 10:01:12 +02:00
Pierre Tachoire
4b5668f4fd Merge pull request #270 from lightpanda-io/nodelist-foreach
DOM: implement nodelist.foreach
2024-09-20 18:37:22 +02:00
Pierre Tachoire
44a5fa011a dom: implement nodelist.foreach 2024-09-20 18:32:23 +02:00
Francis Bouvier
14a3a662fd Fix response of runtime.Evaluate
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-07-09 16:10:25 +02:00
Francis Bouvier
41409031fd Adapt to refacto in js_exec from zig-js-runtime
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-07-08 22:51:41 +02:00
Francis Bouvier
ea410c8ced Fix changes in Zig 0.12 std lib
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-20 00:32:06 +02:00
Francis Bouvier
aca64eedca Uniformize calling name conventions
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-19 15:56:44 +02:00
Francis Bouvier
0f8b47b598 Move MsgBuffer in it's own file for unit test purpose
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-19 15:48:20 +02:00
Francis Bouvier
5eae15889d Add some optional fields in Runtime.evaluate
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-19 15:23:09 +02:00
Francis Bouvier
9319e4a7f1 Handle Runtime.callFunctionOn
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-17 16:35:22 +02:00
Francis Bouvier
4d756b5bfc Add a dumpFile utility function
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-17 16:34:47 +02:00
Francis Bouvier
409969621d Add Runtime.addBinding
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-12 17:56:54 +02:00
Francis Bouvier
7abb7277c9 Fix call to Runtime.executionContextCreated in Page.navigate
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-12 17:56:07 +02:00
Francis Bouvier
9120b9c1de Add emulation.setTouchEmulationEnabled
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-07 16:19:08 +02:00
Francis Bouvier
08c11ac41f Add performance.enable
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-07 16:16:15 +02:00
Francis Bouvier
cecc03e1ed Add fetch.disable
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-07 16:12:31 +02:00
Francis Bouvier
7d67d131c2 Add network.setCacheDisabled
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-07 16:08:49 +02:00
Francis Bouvier
1929eed8ac Add contextID in state
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-07 16:03:12 +02:00
Francis Bouvier
ad8c9fac2b Add target.setDiscoverTargets
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-07 16:02:37 +02:00
Francis Bouvier
fa82160265 Add target.getBrowserContexts
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-07 16:02:02 +02:00
Francis Bouvier
dc1456f4e8 Handle CDP messages with different order
The 'method' still needs to be the first or the second key
(in this case after the 'id').

Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-06-07 15:59:57 +02:00
Francis Bouvier
3ad19dffa1 Handle CDP msg with order <id, method> and <method, id>
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-05-30 17:43:01 +02:00
Francis Bouvier
bfb9db235e Basic Runtime.evaluate run
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-05-30 16:21:18 +02:00
Francis Bouvier
c57e50c5b9 Handle Runtime.evaluate (no-op)
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-05-27 16:02:14 +02:00
Francis Bouvier
bafdca3ffa MsgBuffer to handle both combined and multipart read
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-05-22 16:24:39 +02:00
Francis Bouvier
ba12945e5b Move read input from Cmd callback to allow unit tests
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-24 11:17:55 +02:00
Francis Bouvier
96906df64b Implement own protocol to handle msg size
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-23 12:48:35 +02:00
Francis Bouvier
3396c70b67 Send Runtime.executionContextCreated events in Page.navigate
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-23 10:44:33 +02:00
Francis Bouvier
28d5c682cd Use sendEvent in Runtime.executionContextCreated and expose it
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-23 10:44:03 +02:00
Francis Bouvier
7a03562a33 Typo fix Page.LifecycleEvent
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-23 10:43:11 +02:00
Francis Bouvier
4a31dd8aa3 Let Page.navigate do actually navigation
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-19 17:13:32 +02:00
Francis Bouvier
1b1b7cdfb0 Add page_life_cycle_events in CDP state
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-19 17:12:37 +02:00
Francis Bouvier
9e13ffb8ff Add sendEvent utility function
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-19 17:11:31 +02:00
Francis Bouvier
ed38705efd Basic version using Browser
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-19 11:57:44 +02:00
Francis Bouvier
1a1cd0353c Add dummy Page.navigate
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-19 11:36:02 +02:00
Francis Bouvier
4f0b071c59 Fix getContent algo
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-19 11:35:47 +02:00
Francis Bouvier
9ce574a1f0 Add Page.createIsolatedWorld
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 21:57:31 +02:00
Francis Bouvier
c54b50eb0c Add Browser.setWindowBounds
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 21:52:06 +02:00
Francis Bouvier
aec7455151 Add Emulation.setDeviceMetricsOverride
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 21:46:43 +02:00
Francis Bouvier
c7ba567d7f Handle non-empty void params in getContent
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 21:45:46 +02:00
Francis Bouvier
fc1b3d5397 Contextual frameTree
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 20:54:30 +02:00
Francis Bouvier
508741c367 Add Browser.getWindowForTarget
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 20:53:18 +02:00
Francis Bouvier
f02de77295 Add getContent
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 20:38:27 +02:00
Francis Bouvier
9974b56607 Add Target.createTarget
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 16:43:19 +02:00
Francis Bouvier
0506a7bb53 Add Browser.createBrowserContext
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 16:14:19 +02:00
Francis Bouvier
06f161c423 Add Target.getTargetInfo
+ do not send attachedToTarget if sessionId

Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 13:20:23 +02:00
Francis Bouvier
69f5bb9ed3 Add sessionId in Runime.runIfWaitingForDebugger response
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 13:18:51 +02:00
Francis Bouvier
490eb40028 Add method cdp function
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 13:18:16 +02:00
Francis Bouvier
43a558f5ae Make getParams return nullable
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 12:10:20 +02:00
Francis Bouvier
e4ae2df1a4 Add some optional params in methods
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 11:57:39 +02:00
Francis Bouvier
1620138421 Return sessionId in Emulation.setFocusEmulationEnabled
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 11:32:11 +02:00
Francis Bouvier
e59fc903f2 Return a result in Page.getFrameTree
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-18 10:20:47 +02:00
Francis Bouvier
4d8cdc6dc8 Handle sessionId in result
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-17 14:18:18 +02:00
Francis Bouvier
21afa1f4b3 Do not emit optional null value in JSON output
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-17 14:04:34 +02:00
Francis Bouvier
05c5d06df5 Change Page.addScriptToEvaluateOnNewDocument
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-16 17:28:28 +02:00
Francis Bouvier
9e8b765f7a Allow method with sessionId and use it when appropriate (*.enable)
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-16 17:19:50 +02:00
Francis Bouvier
36dbc28bde Add Runtime.runIfWaitingForDebugger
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-16 16:40:50 +02:00
Francis Bouvier
26eda90f7e Add setFocusEmulationEnabled
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-16 16:38:47 +02:00
Francis Bouvier
211fa3d947 Handle several JSON msg in 1 read
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-16 16:38:15 +02:00
Francis Bouvier
67bbd9957d Add Network domain
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-16 01:03:04 +02:00
Francis Bouvier
aff2250504 Add Emulation domain
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-16 01:02:44 +02:00
Francis Bouvier
86b1c851c0 Add Page.addScriptToEvaluateOnNewDocument
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-16 00:57:25 +02:00
Francis Bouvier
0a03dcb465 Add Page.setLifecycleEventsEnabled
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-16 00:54:40 +02:00
Francis Bouvier
e073e3388d Add Runtime domain
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-16 00:50:17 +02:00
Francis Bouvier
626fae0da0 Add Log domain
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-16 00:48:40 +02:00
Francis Bouvier
a708a7f387 Add Page.getFrameTree
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-16 00:48:17 +02:00
Francis Bouvier
b1242207a9 Add Page domain
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-16 00:41:26 +02:00
Francis Bouvier
980571073d Big refacto
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-16 00:38:06 +02:00
Francis Bouvier
5e1fe656e8 send Target.attachedToTarget after Target.setAutoAttach
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-15 21:34:00 +02:00
Francis Bouvier
ffbfd36502 Add stringify function in cdp
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-15 21:33:32 +02:00
Francis Bouvier
e908cb0ec4 Use send as normal behavior in cmdCallback
+ add nanoseconds param in sendLater

Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-15 21:32:14 +02:00
Francis Bouvier
95a64b7696 Handle concurrent calls to sendLater
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-15 17:57:33 +02:00
Francis Bouvier
cfd6fc9532 Working sendLater (I/O timeout)
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-15 17:36:22 +02:00
Francis Bouvier
defab0c774 Free msg at the right place
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-15 15:52:13 +02:00
Francis Bouvier
babac692d5 Remove alloc from CmdContext struct
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-15 15:47:19 +02:00
Francis Bouvier
c57bb9ef72 WIP: CDP
Signed-off-by: Francis Bouvier <francis@lightpanda.io>
2024-04-15 12:14:33 +02:00
152 changed files with 37357 additions and 11638 deletions

View File

@@ -5,7 +5,7 @@ inputs:
zig:
description: 'Zig version to install'
required: false
default: '0.13.0'
default: '0.14.0'
arch:
description: 'CPU arch used to select the v8 lib'
required: false
@@ -17,7 +17,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.1.6'
default: 'v0.1.20'
v8:
description: 'v8 version to install'
required: false
@@ -47,7 +47,7 @@ runs:
cache-name: cache-v8
with:
path: ${{ inputs.cache-dir }}/v8
key: libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}.a
key: libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}_${{ inputs.zig-v8 }}.a
- if: ${{ steps.cache-v8.outputs.cache-hit != 'true' }}
shell: bash
@@ -59,11 +59,11 @@ runs:
- name: install v8
shell: bash
run: |
mkdir -p vendor/zig-js-runtime/vendor/v8/${{inputs.arch}}-${{inputs.os}}/debug
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a vendor/zig-js-runtime/vendor/v8/${{inputs.arch}}-${{inputs.os}}/debug/libc_v8.a
mkdir -p v8/build/${{inputs.arch}}-${{inputs.os}}/debug/ninja/obj/zig/
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/build/${{inputs.arch}}-${{inputs.os}}/debug/ninja/obj/zig/libc_v8.a
mkdir -p vendor/zig-js-runtime/vendor/v8/${{inputs.arch}}-${{inputs.os}}/release
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a vendor/zig-js-runtime/vendor/v8/${{inputs.arch}}-${{inputs.os}}/release/libc_v8.a
mkdir -p v8/build/${{inputs.arch}}-${{inputs.os}}/release/ninja/obj/zig/
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/build/${{inputs.arch}}-${{inputs.os}}/release/ninja/obj/zig/libc_v8.a
- name: libiconv
shell: bash

View File

@@ -16,29 +16,63 @@ jobs:
ARCH: x86_64
OS: linux
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_CI_PAT }}
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: zig build
run: zig build --release=safe -Doptimize=ReleaseSafe -Dengine=v8 -Dcpu=x86_64
run: zig build --release=safe -Doptimize=ReleaseSafe -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/browsercore-get lightpanda-get-${{ env.ARCH }}-${{ env.OS }}
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: Upload the build
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: lightpanda-get-${{ env.ARCH }}-${{ env.OS }}
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
build-linux-aarch64:
env:
ARCH: aarch64
OS: linux
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: zig build
run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: Upload the build
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
build-macos-aarch64:
@@ -52,7 +86,6 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_CI_PAT }}
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
@@ -62,14 +95,46 @@ jobs:
arch: ${{env.ARCH}}
- name: zig build
run: zig build --release=safe -Doptimize=ReleaseSafe -Dengine=v8
run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/browsercore-get lightpanda-get-${{ env.ARCH }}-${{ env.OS }}
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: Upload the build
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: lightpanda-get-${{ env.ARCH }}-${{ env.OS }}
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
build-macos-x86_64:
env:
ARCH: x86_64
OS: macos
runs-on: macos-13
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: zig build
run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
- name: Upload the build
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly

32
.github/workflows/cla.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: "CLA Assistant"
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened,closed,synchronize]
permissions:
actions: write
contents: read
pull-requests: write
statuses: write
jobs:
CLAAssistant:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_GH_PAT }}
with:
path-to-signatures: 'signatures/browser/version1/cla.json'
path-to-document: 'https://github.com/lightpanda-io/browser/blob/main/CLA.md'
# branch should not be protected
branch: 'main'
allowlist: krichprollsch,francisbouvier,katie-lpd,sjorsdonkers,bornlex
remote-organization-name: lightpanda-io
remote-repository-name: cla

134
.github/workflows/e2e-test.yml vendored Normal file
View File

@@ -0,0 +1,134 @@
name: e2e-test
on:
push:
branches:
- main
paths:
- "build.zig"
- "src/**/*.zig"
- "src/*.zig"
- "vendor/zig-js-runtime"
- ".github/**"
- "vendor/**"
pull_request:
# By default GH trigger on types opened, synchronize and reopened.
# see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
# Since we skip the job when the PR is in draft state, we want to force CI
# running when the PR is marked ready_for_review w/o other change.
# see https://github.com/orgs/community/discussions/25722#discussioncomment-3248917
types: [opened, synchronize, reopened, ready_for_review]
paths:
- ".github/**"
- "build.zig"
- "src/**/*.zig"
- "src/*.zig"
- "vendor/**"
- ".github/**"
- "vendor/**"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
jobs:
zig-build-release:
name: zig build release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
- name: zig build release
run: zig build -Doptimize=ReleaseSafe
- name: upload artifact
uses: actions/upload-artifact@v4
with:
name: lightpanda-build-release
path: |
zig-out/bin/lightpanda
retention-days: 1
puppeteer-perf:
name: puppeteer-perf
needs: zig-build-release
env:
MAX_MEMORY: 28000
MAX_AVG_DURATION: 24
LIGHTPANDA_DISABLE_TELEMETRY: true
runs-on: ubuntu-latest
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 --gc_hints & 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
runs-on: ubuntu-latest
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 end to end tests
run: |
./lightpanda serve & echo $! > LPD.pid
go run runner/main.go --verbose
kill `cat LPD.pid`

View File

@@ -26,14 +26,14 @@ on:
# see https://github.com/orgs/community/discussions/25722#discussioncomment-3248917
types: [opened, synchronize, reopened, ready_for_review]
paths:
- ".github/**"
- "build.zig"
- "src/**/*.zig"
- "src/*.zig"
- "tests/wpt/**"
- "vendor/**"
- ".github/**"
# paths:
# - ".github/**"
# - "build.zig"
# - "src/**/*.zig"
# - "src/*.zig"
# - "tests/wpt/**"
# - "vendor/**"
# - ".github/**"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -41,8 +41,8 @@ jobs:
wpt:
name: web platform tests
# Don't run the CI with draft PR.
if: github.event.pull_request.draft == false
# Only for PR without draft.
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
runs-on: ubuntu-latest
@@ -50,21 +50,37 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_CI_PAT }}
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
- run: zig build wpt -Dengine=v8 -- --safe --summary
- run: zig build wpt -- --summary
# For now WPT tests doesn't pass at all.
# We accept then to continue the job on failure.
# TODO remove the continue-on-error when tests will pass.
continue-on-error: true
wpt-json:
name: web platform tests json output
# Don't execute on PR
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
- name: json output
run: zig build wpt -Dengine=v8 -- --safe --json > wpt.json
run: zig build wpt -- --json > wpt.json
- name: write commit
run: |
@@ -81,7 +97,7 @@ jobs:
perf-fmt:
name: perf-fmt
needs: wpt
needs: wpt-json
# Don't execute on PR
if: github.event_name != 'pull_request'

View File

@@ -1,7 +1,7 @@
name: zig-fmt
env:
ZIG_VERSION: 0.13.0
ZIG_VERSION: 0.14.0
on:
pull_request:

View File

@@ -16,6 +16,7 @@ on:
- "src/*.zig"
- "vendor/zig-js-runtime"
- ".github/**"
- "vendor/**"
pull_request:
# By default GH trigger on types opened, synchronize and reopened.
@@ -32,6 +33,7 @@ on:
- "src/*.zig"
- "vendor/**"
- ".github/**"
- "vendor/**"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -48,35 +50,40 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_CI_PAT }}
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
- name: zig build debug
run: zig build -Dengine=v8
run: zig build
zig-build-release:
name: zig build release
- name: upload artifact
uses: actions/upload-artifact@v4
with:
name: lightpanda-build-dev
path: |
zig-out/bin/lightpanda
retention-days: 1
# Don't run the CI on PR
if: github.event_name != 'pull_request'
browser-fetch:
name: browser fetch
needs: zig-build-dev
# Don't run the CI with draft PR.
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: download artifact
uses: actions/download-artifact@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_CI_PAT }}
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
name: lightpanda-build-dev
- uses: ./.github/actions/install
- run: chmod a+x ./lightpanda
- name: zig build release
run: zig build -Doptimize=ReleaseSafe -Dengine=v8
- run: ./lightpanda fetch https://httpbin.io/xhr/get
zig-test:
name: zig test
@@ -90,14 +97,13 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GH_CI_PAT }}
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
- name: zig build test
run: zig build test -Dengine=v8 -- --json > bench.json
run: zig build test -- --json > bench.json
- name: write commit
run: |

6
.gitignore vendored
View File

@@ -1,7 +1,7 @@
zig-cache
/.zig-cache/
zig-out
/vendor/netsurf/build/
/vendor/netsurf/lib/
/vendor/netsurf/include/
/vendor/netsurf/out
/vendor/libiconv/
lightpanda.id
/v8/

16
.gitmodules vendored
View File

@@ -1,27 +1,21 @@
[submodule "vendor/zig-js-runtime"]
path = vendor/zig-js-runtime
url = git@github.com:lightpanda-io/zig-js-runtime.git
[submodule "vendor/netsurf/libwapcaplet"]
path = vendor/netsurf/libwapcaplet
url = git@github.com:lightpanda-io/libwapcaplet.git
url = https://github.com/lightpanda-io/libwapcaplet.git/
[submodule "vendor/netsurf/libparserutils"]
path = vendor/netsurf/libparserutils
url = git@github.com:lightpanda-io/libparserutils.git
url = https://github.com/lightpanda-io/libparserutils.git/
[submodule "vendor/netsurf/libdom"]
path = vendor/netsurf/libdom
url = git@github.com:lightpanda-io/libdom.git
url = https://github.com/lightpanda-io/libdom.git/
[submodule "vendor/netsurf/share/netsurf-buildsystem"]
path = vendor/netsurf/share/netsurf-buildsystem
url = https://source.netsurf-browser.org/buildsystem.git
[submodule "vendor/netsurf/libhubbub"]
path = vendor/netsurf/libhubbub
url = git@github.com:lightpanda-io/libhubbub.git
url = https://github.com/lightpanda-io/libhubbub.git/
[submodule "tests/wpt"]
path = tests/wpt
url = https://github.com/lightpanda-io/wpt
[submodule "vendor/mimalloc"]
path = vendor/mimalloc
url = git@github.com:microsoft/mimalloc.git
[submodule "vendor/tls.zig"]
path = vendor/tls.zig
url = git@github.com:ianic/tls.zig.git
url = https://github.com/microsoft/mimalloc.git/

93
CLA.md Normal file
View File

@@ -0,0 +1,93 @@
# Lightpanda (Selecy SAS) Grant and Contributor License Agreement (“Agreement”)
This agreement is based on the Apache Software Foundation Contributor License
Agreement. (v r190612)
Thank you for your interest in software projects stewarded by Lightpanda
(Selecy SAS) (“Lightpanda”). In order to clarify the intellectual property
license granted with Contributions from any person or entity, Lightpanda must
have a Contributor License Agreement (CLA) on file that has been agreed to by
each Contributor, indicating agreement to the license terms below. This license
is for your protection as a Contributor as well as the protection of Lightpanda
and its users; it does not change your rights to use your own Contributions for
any other purpose. This Agreement allows an individual to contribute to
Lightpanda on that individuals own behalf, or an entity (the “Corporation”) to
submit Contributions to Lightpanda, to authorize Contributions submitted by its
designated employees to Lightpanda, and to grant copyright and patent licenses
thereto.
You accept and agree to the following terms and conditions for Your present and
future Contributions submitted to Lightpanda. Except for the license granted
herein to Lightpanda and recipients of software distributed by Lightpanda, You
reserve all right, title, and interest in and to Your Contributions.
1. Definitions. “You” (or “Your”) shall mean the copyright owner or legal
entity authorized by the copyright owner that is making this Agreement with
Lightpanda. For legal entities, the entity making a Contribution and all
other entities that control, are controlled by, or are under common control
with that entity are considered to be a single Contributor. For the purposes
of this definition, “control” means (i) the power, direct or indirect, to
cause the direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
“Contribution” shall mean any work, as well as any modifications or
additions to an existing work, that is intentionally submitted by You to
Lightpanda for inclusion in, or documentation of, any of the products owned
or managed by Lightpanda (the “Work”). For the purposes of this definition,
“submitted” means any form of electronic, verbal, or written communication
sent to Lightpanda or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems (such
as GitHub), and issue tracking systems that are managed by, or on behalf of,
Lightpanda for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise designated
in writing by You as “Not a Contribution.”
2. Grant of Copyright License. Subject to the terms and conditions of this
Agreement, You hereby grant to Lightpanda and to recipients of software
distributed by Lightpanda a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable copyright license to reproduce, prepare derivative
works of, publicly display, publicly perform, sublicense, and distribute
Your Contributions and such derivative works.
3. Grant of Patent License. Subject to the terms and conditions of this
Agreement, You hereby grant to Lightpanda and to recipients of software
distributed by Lightpanda a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable (except as stated in this section) patent license
to make, have made, use, offer to sell, sell, import, and otherwise transfer
the Work, where such license applies only to those patent claims licensable
by You that are necessarily infringed by Your Contribution(s) alone or by
combination of Your Contribution(s) with the Work to which such
Contribution(s) were submitted. If any entity institutes patent litigation
against You or any other entity (including a cross-claim or counterclaim in
a lawsuit) alleging that your Contribution, or the Work to which you have
contributed, constitutes direct or contributory patent infringement, then
any patent licenses granted to that entity under this Agreement for that
Contribution or Work shall terminate as of the date such litigation is
filed.
4. You represent that You are legally entitled to grant the above license. If
You are an individual, and if Your employer(s) has rights to intellectual
property that you create that includes Your Contributions, you represent
that You have received permission to make Contributions on behalf of that
employer, or that Your employer has waived such rights for your
Contributions to Lightpanda. If You are a Corporation, any individual who
makes a contribution from an account associated with You will be considered
authorized to Contribute on Your behalf.
5. You represent that each of Your Contributions is Your original creation (see
section 7 for submissions on behalf of others).
6. You are not expected to provide support for Your Contributions,except to the
extent You desire to provide support. You may provide support for free, for
a fee, or not at all. Unless required by applicable law or agreed to in
writing, You provide Your Contributions on an “AS IS” BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including,
without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT,
MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
7. Should You wish to submit work that is not Your original creation, You may
submit it to Lightpanda separately from any Contribution, identifying the
complete details of its source and of any license or other restriction
(including, but not limited to, related patents, trademarks, and license
agreements) of which you are personally aware, and conspicuously marking the
work as “Submitted on behalf of a third-party: [named here]”.

10
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,10 @@
# Contributing
Lightpanda accepts pull requests through GitHub.
You have to sign our [CLA](CLA.md) during your first pull request process
otherwise we're not able to accept your contributions.
The process signature uses the [CLA assistant
lite](https://github.com/marketplace/actions/cla-assistant-lite). You can see
an example of the process in [#303](https://github.com/lightpanda-io/browser/pull/303).

75
Dockerfile Normal file
View File

@@ -0,0 +1,75 @@
FROM ubuntu:24.04
ARG MINISIG=0.12
ARG ZIG=0.14.0
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG ARCH=x86_64
ARG V8=11.1.134
ARG ZIG_V8=v0.1.20
RUN apt-get update -yq && \
apt-get install -yq xz-utils \
python3 ca-certificates git \
pkg-config libglib2.0-dev \
gperf libexpat1-dev \
cmake clang \
curl git
# install minisig
RUN curl --fail -L -O https://github.com/jedisct1/minisign/releases/download/${MINISIG}/minisign-${MINISIG}-linux.tar.gz && \
tar xvzf minisign-${MINISIG}-linux.tar.gz
# install zig
RUN curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-linux-${ARCH}-${ZIG}.tar.xz
RUN curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-linux-${ARCH}-${ZIG}.tar.xz.minisig
RUN minisign-linux/${ARCH}/minisign -Vm zig-linux-${ARCH}-${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-linux-${ARCH}-${ZIG}.tar.xz && \
mv zig-linux-${ARCH}-${ZIG} /usr/local/lib && \
ln -s /usr/local/lib/zig-linux-${ARCH}-${ZIG}/zig /usr/local/bin/zig
# clean up zig install
RUN rm -fr zig-linux-${ARCH}-${ZIG}.tar.xz zig-linux-${ARCH}-${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
WORKDIR /browser
# install deps
RUN git submodule init && \
git submodule update --recursive
RUN make install-libiconv && \
make install-netsurf && \
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 && \
mkdir -p v8/build/${ARCH}-linux/release/ninja/obj/zig/ && \
mv libc_v8.a v8/build/${ARCH}-linux/release/ninja/obj/zig/libc_v8.a
# build release
RUN make build
FROM ubuntu:24.04
# copy ca certificates
COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=0 /browser/zig-out/bin/lightpanda /bin/lightpanda
EXPOSE 9222/tcp
CMD ["/bin/lightpanda", "serve", "--host", "0.0.0.0", "--port", "9222"]

22
LICENSING.md Normal file
View File

@@ -0,0 +1,22 @@
# Licensing
License names used in this document are as per [SPDX License
List](https://spdx.org/licenses/).
The default license for this project is [AGPL-3.0-only](LICENSE).
## MIT
The following files are licensed under MIT:
```
src/polyfill/fetch.js
```
The following directories and their subdirectories are licensed under their
original upstream licenses:
```
vendor/
tests/wpt/
```

156
Makefile
View File

@@ -3,6 +3,30 @@
ZIG := zig
BC := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
# option test filter make test F="server"
F=
# OS and ARCH
kernel = $(shell uname -ms)
ifeq ($(kernel), Darwin arm64)
OS := macos
ARCH := aarch64
else ifeq ($(kernel), Darwin x86_64)
OS := macos
ARCH := x86_64
else ifeq ($(kernel), Linux aarch64)
OS := linux
ARCH := aarch64
else ifeq ($(kernel), Linux arm64)
OS := linux
ARCH := aarch64
else ifeq ($(kernel), Linux x86_64)
OS := linux
ARCH := x86_64
else
$(error "Unhandled kernel: $(kernel)")
endif
# Infos
# -----
@@ -23,33 +47,15 @@ help:
# $(ZIG) commands
# ------------
.PHONY: build build-dev run run-release shell test bench download-zig wpt
.PHONY: build build-dev run run-release shell test bench download-zig wpt data get-v8 build-v8 build-v8-dev
.PHONY: end2end
zig_version = $(shell grep 'recommended_zig_version = "' "vendor/zig-js-runtime/build.zig" | cut -d'"' -f2)
kernel = $(shell uname -ms)
## Download the zig recommended version
download-zig:
ifeq ($(kernel), Darwin x86_64)
$(eval target="macos")
$(eval arch="x86_64")
else ifeq ($(kernel), Darwin arm64)
$(eval target="macos")
$(eval arch="aarch64")
else ifeq ($(kernel), Linux aarch64)
$(eval target="linux")
$(eval arch="aarch64")
else ifeq ($(kernel), Linux arm64)
$(eval target="linux")
$(eval arch="aarch64")
else ifeq ($(kernel), Linux x86_64)
$(eval target="linux")
$(eval arch="x86_64")
else
$(error "Unhandled kernel: $(kernel)")
endif
$(eval url = "https://ziglang.org/builds/zig-$(target)-$(arch)-$(zig_version).tar.xz")
$(eval dest = "/tmp/zig-$(target)-$(arch)-$(zig_version).tar.xz")
$(eval url = "https://ziglang.org/download/$(zig_version)/zig-$(OS)-$(ARCH)-$(zig_version).tar.xz")
$(eval dest = "/tmp/zig-$(OS)-$(ARCH)-$(zig_version).tar.xz")
@printf "\e[36mDownload zig version $(zig_version)...\e[0m\n"
@curl -o "$(dest)" -L "$(url)" || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
@printf "\e[33mDownloaded $(dest)\e[0m\n"
@@ -57,53 +63,69 @@ endif
## Build in release-safe mode
build:
@printf "\e[36mBuilding (release safe)...\e[0m\n"
@$(ZIG) build -Doptimize=ReleaseSafe -Dengine=v8 || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
$(ZIG) build -Doptimize=ReleaseSafe -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
@printf "\e[33mBuild OK\e[0m\n"
## Build in debug mode
build-dev:
@printf "\e[36mBuilding (debug)...\e[0m\n"
@$(ZIG) build -Dengine=v8 || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
@$(ZIG) build -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
@printf "\e[33mBuild OK\e[0m\n"
## Run the server in debug mode
run: build
@printf "\e[36mRunning...\e[0m\n"
@./zig-out/bin/browsercore || (printf "\e[33mRun ERROR\e[0m\n"; exit 1;)
@./zig-out/bin/lightpanda || (printf "\e[33mRun ERROR\e[0m\n"; exit 1;)
## Run a JS shell in debug mode
shell:
@printf "\e[36mBuilding shell...\e[0m\n"
@$(ZIG) build shell -Dengine=v8 || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
@$(ZIG) build shell || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
## Run WPT tests
wpt:
@printf "\e[36mBuilding wpt...\e[0m\n"
@$(ZIG) build wpt -Dengine=v8 -- --safe $(filter-out $@,$(MAKECMDGOALS)) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
@$(ZIG) build wpt -- $(filter-out $@,$(MAKECMDGOALS)) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
wpt-summary:
@printf "\e[36mBuilding wpt...\e[0m\n"
@$(ZIG) build wpt -Dengine=v8 -- --safe --summary $(filter-out $@,$(MAKECMDGOALS)) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
@$(ZIG) build wpt -- --summary $(filter-out $@,$(MAKECMDGOALS)) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
## Test
test:
@printf "\e[36mTesting...\e[0m\n"
@$(ZIG) build test -Dengine=v8 || (printf "\e[33mTest ERROR\e[0m\n"; exit 1;)
@printf "\e[33mTest OK\e[0m\n"
@TEST_FILTER='${F}' $(ZIG) build test -freference-trace --summary all
## Run demo/runner end to end tests
end2end:
@test -d ../demo
cd ../demo && go run runner/main.go
## v8
get-v8:
@printf "\e[36mGetting v8 source...\e[0m\n"
@$(ZIG) build get-v8
build-v8-dev:
@printf "\e[36mBuilding v8 (dev)...\e[0m\n"
@$(ZIG) build build-v8
build-v8:
@printf "\e[36mBuilding v8...\e[0m\n"
@$(ZIG) build -Doptimize=ReleaseSafe build-v8
# Install and build required dependencies commands
# ------------
.PHONY: install-submodule
.PHONY: install-zig-js-runtime install-zig-js-runtime-dev install-libiconv
.PHONY: install-libiconv
.PHONY: _install-netsurf install-netsurf clean-netsurf test-netsurf install-netsurf-dev
.PHONY: install-mimalloc install-mimalloc-dev clean-mimalloc
.PHONY: install-dev install
## Install and build dependencies for release
install: install-submodule install-zig-js-runtime install-netsurf install-mimalloc
install: install-submodule install-libiconv install-netsurf install-mimalloc
## Install and build dependencies for dev
install-dev: install-submodule install-zig-js-runtime-dev install-netsurf-dev install-mimalloc-dev
install-dev: install-submodule install-libiconv install-netsurf-dev install-mimalloc-dev
install-netsurf-dev: _install-netsurf
install-netsurf-dev: OPTCFLAGS := -O0 -g -DNDEBUG
@@ -111,14 +133,16 @@ install-netsurf-dev: OPTCFLAGS := -O0 -g -DNDEBUG
install-netsurf: _install-netsurf
install-netsurf: OPTCFLAGS := -DNDEBUG
BC_NS := $(BC)vendor/netsurf
ICONV := $(BC)vendor/libiconv
BC_NS := $(BC)vendor/netsurf/out/$(OS)-$(ARCH)
ICONV := $(BC)vendor/libiconv/out/$(OS)-$(ARCH)
# TODO: add Linux iconv path (I guess it depends on the distro)
# TODO: this way of linking libiconv is not ideal. We should have a more generic way
# and stick to a specif version. Maybe build from source. Anyway not now.
_install-netsurf: install-libiconv
_install-netsurf: clean-netsurf
@printf "\e[36mInstalling NetSurf...\e[0m\n" && \
ls $(ICONV) 1> /dev/null || (printf "\e[33mERROR: you need to install libiconv in your system (on MacOS on with Homebrew)\e[0m\n"; exit 1;) && \
ls $(ICONV)/lib/libiconv.a 1> /dev/null || (printf "\e[33mERROR: you need to execute 'make install-libiconv'\e[0m\n"; exit 1;) && \
mkdir -p $(BC_NS) && \
cp -R vendor/netsurf/share $(BC_NS) && \
export PREFIX=$(BC_NS) && \
export OPTLDFLAGS="-L$(ICONV)/lib" && \
export OPTCFLAGS="$(OPTCFLAGS) -I$(ICONV)/include" && \
@@ -137,7 +161,7 @@ _install-netsurf: install-libiconv
BUILDDIR=$(BC_NS)/build/libdom make install && \
printf "\e[33mRunning libdom example...\e[0m\n" && \
cd examples && \
zig cc \
$(ZIG) cc \
-I$(ICONV)/include \
-I$(BC_NS)/include \
-L$(ICONV)/lib \
@@ -156,10 +180,7 @@ _install-netsurf: install-libiconv
clean-netsurf:
@printf "\e[36mCleaning NetSurf build...\e[0m\n" && \
cd vendor/netsurf && \
rm -R build && \
rm -R lib && \
rm -R include
rm -Rf $(BC_NS)
test-netsurf:
@printf "\e[36mTesting NetSurf...\e[0m\n" && \
@@ -169,43 +190,50 @@ test-netsurf:
cd vendor/netsurf/libdom && \
BUILDDIR=$(BC_NS)/build/libdom make test
install-libiconv:
ifeq ("$(wildcard vendor/libiconv/lib/libiconv.a)","")
download-libiconv:
ifeq ("$(wildcard vendor/libiconv/libiconv-1.17)","")
@mkdir -p vendor/libiconv
@cd vendor/libiconv && \
curl https://ftp.gnu.org/pub/gnu/libiconv/libiconv-1.17.tar.gz | tar -xvzf -
@cd vendor/libiconv/libiconv-1.17 && \
./configure --prefix=$(BC)vendor/libiconv --enable-static && \
make && make install
endif
install-zig-js-runtime-dev:
@cd vendor/zig-js-runtime && \
make install-dev
install-libiconv: download-libiconv clean-libiconv
@cd vendor/libiconv/libiconv-1.17 && \
./configure --prefix=$(ICONV) --enable-static && \
make && make install
install-zig-js-runtime:
@cd vendor/zig-js-runtime && \
make install
clean-libiconv:
ifneq ("$(wildcard vendor/libiconv/libiconv-1.17/Makefile)","")
@cd vendor/libiconv/libiconv-1.17 && \
make clean
endif
data:
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig
.PHONY: _build_mimalloc
_build_mimalloc:
@cd vendor/mimalloc && \
mkdir -p out/include && \
cp include/mimalloc.h out/include/ && \
cd out && \
cmake -DMI_BUILD_SHARED=OFF -DMI_BUILD_OBJECT=OFF -DMI_BUILD_TESTS=OFF -DMI_OVERRIDE=OFF $(OPTS) .. && \
make
MIMALLOC := $(BC)vendor/mimalloc/out/$(OS)-$(ARCH)
_build_mimalloc: clean-mimalloc
@mkdir -p $(MIMALLOC)/build && \
cd $(MIMALLOC)/build && \
cmake -DMI_BUILD_SHARED=OFF -DMI_BUILD_OBJECT=OFF -DMI_BUILD_TESTS=OFF -DMI_OVERRIDE=OFF $(OPTS) ../../.. && \
make && \
mkdir -p $(MIMALLOC)/lib
install-mimalloc-dev: _build_mimalloc
install-mimalloc-dev: OPTS=-DCMAKE_BUILD_TYPE=Debug
install-mimalloc-dev:
@cd vendor/mimalloc/out && \
mv libmimalloc-debug.a libmimalloc.a
@cd $(MIMALLOC) && \
mv build/libmimalloc-debug.a lib/libmimalloc.a
install-mimalloc: _build_mimalloc
install-mimalloc:
@cd $(MIMALLOC) && \
mv build/libmimalloc.a lib/libmimalloc.a
clean-mimalloc:
@rm -fr vendor/mimalloc/lib/*
@rm -Rf $(MIMALLOC)/build
## Init and update git submodule
install-submodule:

220
README.md
View File

@@ -2,81 +2,150 @@
<a href="https://lightpanda.io"><img src="https://cdn.lightpanda.io/assets/images/logo/lpd-logo.png" alt="Logo" height=170></a>
</p>
<h1 align="center">Lightpanda</h1>
<h1 align="center">Lightpanda Browser</h1>
<p align="center"><a href="https://lightpanda.io/">lightpanda.io</a></p>
<div align="center">
<br />
[![License](https://img.shields.io/github/license/lightpanda-io/browser)](https://github.com/lightpanda-io/browser/blob/main/LICENSE)
[![Twitter Follow](https://img.shields.io/twitter/follow/lightpanda_io)](https://twitter.com/lightpanda_io)
[![GitHub stars](https://img.shields.io/github/stars/lightpanda-io/browser)](https://github.com/lightpanda-io/browser)
</div>
Lightpanda is the open-source browser made for headless usage:
- Javascript execution
- Support of the Web APIs (partial, WIP)
- Support of Web APIs (partial, WIP)
- Compatible with Playwright, Puppeteer through CDP (WIP)
Fast scraping and web automation with minimal memory footprint:
Fast web automation for AI agents, LLM training, scraping and testing:
- Ultra-low memory footprint (12x less than Chrome)
- Blazingly fast & instant startup (64x faster than Chrome)
- Ultra-low memory footprint (9x less than Chrome)
- Exceptionally fast execution (11x faster than Chrome)
- Instant startup
<img width=500px src="https://cdn.lightpanda.io/assets/images/benchmark2.png">
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/execution-time.svg">
](https://github.com/lightpanda-io/demo)
&emsp;
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/memory-frame.svg">
](https://github.com/lightpanda-io/demo)
</div>
See [benchmark details](https://github.com/lightpanda-io/demo).
_Puppeteer requesting 100 pages from a local website on a AWS EC2 m5.large instance.
See [benchmark details](https://github.com/lightpanda-io/demo)._
## Why?
## Quick start
### Javascript execution is mandatory for the modern web
### Install from the nightly builds
Back in the good old times, grabbing a webpage was as easy as making an HTTP request, cURL-like. Its not possible anymore, because Javascript is everywhere, like it or not:
You can download the last binary from the [nightly
builds](https://github.com/lightpanda-io/browser/releases/tag/nightly) for
Linux x86_64 and MacOS aarch64.
- Ajax, Single Page App, Infinite loading, “click to display”, instant search, etc.
- JS web frameworks: React, Vue, Angular & others
*For Linux*
```console
curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-x86_64-linux && \
chmod a+x ./lightpanda
```
### Chrome is not the right tool
*For MacOS*
```console
curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-aarch64-macos && \
chmod a+x ./lightpanda
```
So if we need Javascript, why not use a real web browser. Lets take a huge desktop application, hack it, and run it on the server, right? Hundreds of instance of Chrome if you use it at scale. Are you sure its such a good idea?
*For Windows + WSL2*
- Heavy on RAM and CPU, expensive to run
- Hard to package, deploy and maintain at scale
- Bloated, lots of features are not useful in headless usage
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.
### Lightpanda is built for performance
### Dump a URL
If we want both Javascript and performance, for a real headless browser, we need to start from scratch. Not yet another iteration of Chromium, really from a blank page. Crazy right? But thats we did:
```console
./lightpanda fetch --dump https://lightpanda.io
```
```console
info(browser): GET https://lightpanda.io/ http.Status.ok
info(browser): fetch script https://api.website.lightpanda.io/js/script.js: http.Status.ok
info(browser): eval remote https://api.website.lightpanda.io/js/script.js: TypeError: Cannot read properties of undefined (reading 'pushState')
<!DOCTYPE html>
```
- Not based on Chromium, Blink or WebKit
- Low-level system programming language (Zig) with optimisations in mind
- Opinionated, no rendering
### Start a CDP server
```console
./lightpanda serve --host 127.0.0.1 --port 9222
```
```console
info(websocket): starting blocking worker to listen on 127.0.0.1:9222
info(server): accepting new conn...
```
Once the CDP server started, you can run a Puppeteer script by configuring the
`browserWSEndpoint`.
```js
'use strict'
import puppeteer from 'puppeteer-core';
// use browserWSEndpoint to pass the Lightpanda's CDP server address.
const browser = await puppeteer.connect({
browserWSEndpoint: "ws://127.0.0.1:9222",
});
// The rest of your script remains the same.
const context = await browser.createBrowserContext();
const page = await context.newPage();
// Dump all the links from the page.
await page.goto('https://wikipedia.com/');
const links = await page.evaluate(() => {
return Array.from(document.querySelectorAll('a')).map(row => {
return row.getAttribute('href');
});
});
console.log(links);
await page.close();
await context.close();
await browser.disconnect();
```
### Telemetry
By default, Lightpanda collects and sends usage telemetry. This can be disabled by setting an environment variable `LIGHTPANDA_DISABLE_TELEMETRY=true`. You can read Lightpanda's privacy policy at: [https://lightpanda.io/privacy-policy](https://lightpanda.io/privacy-policy).
## Status
Lightpanda is still a work in progress and is currently at the Alpha stage.
Lightpanda is still a work in progress and is currently at a Beta stage.
Here are the key features we want to implement before releasing a Beta version:
:warning: You should expect most websites to fail or crash.
- [x] Loader
- [x] HTML parser and DOM tree
- [x] Javascript support
Here are the key features we have implemented:
- [x] HTTP loader
- [x] HTML parser and DOM tree (based on Netsurf libs)
- [x] Javascript support (v8)
- [x] Basic DOM APIs
- [x] Ajax
- [x] XHR API
- [ ] Fetch API
- [x] Fetch API
- [x] DOM dump
- [ ] Basic CDP server
- [x] Basic CDP/websockets server
We will not provide binary versions until we reach at least the Beta stage.
NOTE: There are hundreds of Web APIs. Developing a browser, even just for headless mode, is a huge task. It's more about coverage than a _working/not working_ binary situation.
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
You can also follow the progress of our Javascript support in our dedicated [zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime#development) project.
## Build from sources
We do not provide yet binary versions of Lightpanda, you have to compile it from source.
### Prerequisites
Lightpanda is written with [Zig](https://ziglang.org/) `0.13.0`. You have to
Lightpanda is written with [Zig](https://ziglang.org/) `0.14.0`. You have to
install it with the right version in order to build the project.
Lightpanda also depends on
@@ -96,6 +165,11 @@ sudo apt install xz-utils \
cmake clang
```
For systems with [Nix](https://nixos.org/download/), you can use the devShell:
```
nix develop
```
For MacOS, you only need cmake:
```
@@ -108,9 +182,9 @@ brew install cmake
You can run `make install` to install deps all in one (or `make install-dev` if you need the development versions).
Be aware that the build task is very long and cpu consuming, as you will build from sources all dependancies, including the v8 Javascript engine.
Be aware that the build task is very long and cpu consuming, as you will build from sources all dependencies, including the v8 Javascript engine.
#### Step by step build dependancy
#### Step by step build dependency
The project uses git submodules for dependencies.
@@ -120,6 +194,14 @@ To init or update the submodules in the `vendor/` directory:
make install-submodule
```
**iconv**
libiconv is an internationalization library used by Netsurf.
```
make install-libiconv
```
**Netsurf libs**
Netsurf libs are used for HTML parsing and DOM tree generation.
@@ -144,17 +226,21 @@ Note: when Mimalloc is built in dev mode, you can dump memory stats with the
env var `MIMALLOC_SHOW_STATS=1`. See
[https://microsoft.github.io/mimalloc/environment.html](https://microsoft.github.io/mimalloc/environment.html).
**zig-js-runtime**
**v8**
Our own Zig/Javascript runtime, which includes the v8 Javascript engine.
This build task is very long and cpu consuming, as you will build v8 from sources.
First, get the tools necessary for building V8, as well as the V8 source code:
```
make install-zig-js-runtime
make get-v8
```
For dev env, use `make iinstall-zig-js-runtime-dev`.
Next, build v8. This build task is very long and cpu consuming, as you will build v8 from sources.
```
make build-v8
```
For dev env, use `make build-v8-dev`.
## Test
@@ -162,6 +248,20 @@ For dev env, use `make iinstall-zig-js-runtime-dev`.
You can test Lightpanda by running `make test`.
### End to end tests
To run end to end tests, you need to clone the [demo
repository](https://github.com/lightpanda-io/demo) into `../demo` dir.
You have to install the [demo's node
requirements](https://github.com/lightpanda-io/demo?tab=readme-ov-file#dependencies-1)
You also need to install [Go](https://go.dev) > v1.24.
```
make end2end
```
### Web Platform Tests
Lightpanda is tested against the standardized [Web Platform
@@ -196,3 +296,35 @@ To add a new test, copy the file you want from the [WPT
repo](https://github.com/web-platform-tests/wpt) into the `tests/wpt` directory.
:warning: Please keep the original directory tree structure of `tests/wpt`.
## Contributing
Lightpanda accepts pull requests through GitHub.
You have to sign our [CLA](CLA.md) during the pull request process otherwise
we're not able to accept your contributions.
## Why?
### Javascript execution is mandatory for the modern web
In the good old days, scraping a webpage was as easy as making an HTTP request, cURL-like. Its not possible anymore, because Javascript is everywhere, like it or not:
- Ajax, Single Page App, infinite loading, “click to display”, instant search, etc.
- JS web frameworks: React, Vue, Angular & others
### Chrome is not the right tool
If we need Javascript, why not use a real web browser? Take a huge desktop application, hack it, and run it on the server. Hundreds or thousands of instances of Chrome if you use it at scale. Are you sure its such a good idea?
- Heavy on RAM and CPU, expensive to run
- Hard to package, deploy and maintain at scale
- Bloated, lots of features are not useful in headless usage
### Lightpanda is built for performance
If we want both Javascript and performance in a true headless browser, we need to start from scratch. Not another iteration of Chromium, really from a blank page. Crazy right? But thats what we did:
- Not based on Chromium, Blink or WebKit
- Low-level system programming language (Zig) with optimisations in mind
- Opinionated: without graphical rendering

365
build.zig
View File

@@ -17,16 +17,11 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const builtin = @import("builtin");
const jsruntime_path = "vendor/zig-js-runtime/";
const jsruntime = @import("vendor/zig-js-runtime/build.zig");
const jsruntime_pkgs = jsruntime.packages(jsruntime_path);
/// Do not rename this constant. It is scanned by some scripts to determine
/// which zig version to install.
const recommended_zig_version = jsruntime.recommended_zig_version;
const recommended_zig_version = "0.14.0";
pub fn build(b: *std.Build) !void {
switch (comptime builtin.zig_version.order(std.SemanticVersion.parse(recommended_zig_version) catch unreachable)) {
@@ -42,176 +37,190 @@ pub fn build(b: *std.Build) !void {
},
}
const target = b.standardTargetOptions(.{});
const mode = b.standardOptimizeOption(.{});
const options = try jsruntime.buildOptions(b);
const x86 = b.option(bool, "x86", "Use x86 backend") orelse false;
// browser
// -------
// compile and install
const exe = b.addExecutable(.{
.name = "browsercore",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = mode,
.use_llvm = !x86,
.use_lld = !x86,
});
try common(b, exe, options);
b.installArtifact(exe);
// run
const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| {
run_cmd.addArgs(args);
}
// step
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
// shell
// -----
// compile and install
const shell = b.addExecutable(.{
.name = "browsercore-shell",
.root_source_file = b.path("src/main_shell.zig"),
.target = target,
.optimize = mode,
.use_llvm = !x86,
.use_lld = !x86,
});
try common(b, shell, options);
try jsruntime_pkgs.add_shell(shell);
// run
const shell_cmd = b.addRunArtifact(shell);
if (b.args) |args| {
shell_cmd.addArgs(args);
}
// step
const shell_step = b.step("shell", "Run JS shell");
shell_step.dependOn(&shell_cmd.step);
// test
// ----
// compile
const tests = b.addTest(.{
.root_source_file = b.path("src/run_tests.zig"),
.test_runner = b.path("src/test_runner.zig"),
.target = target,
.optimize = mode,
.use_llvm = !x86,
.use_lld = !x86,
});
try common(b, tests, options);
// add jsruntime pretty deps
tests.root_module.addAnonymousImport("pretty", .{
.root_source_file = b.path("vendor/zig-js-runtime/src/pretty.zig"),
});
const run_tests = b.addRunArtifact(tests);
if (b.args) |args| {
run_tests.addArgs(args);
}
// step
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_tests.step);
// wpt
// -----
// compile and install
const wpt = b.addExecutable(.{
.name = "browsercore-wpt",
.root_source_file = b.path("src/main_wpt.zig"),
.target = target,
.optimize = mode,
.use_llvm = !x86,
.use_lld = !x86,
});
try common(b, wpt, options);
// run
const wpt_cmd = b.addRunArtifact(wpt);
if (b.args) |args| {
wpt_cmd.addArgs(args);
}
// step
const wpt_step = b.step("wpt", "WPT tests");
wpt_step.dependOn(&wpt_cmd.step);
// get
// -----
// compile and install
const get = b.addExecutable(.{
.name = "browsercore-get",
.root_source_file = b.path("src/main_get.zig"),
.target = target,
.optimize = mode,
.use_llvm = !x86,
.use_lld = !x86,
});
try common(b, get, options);
b.installArtifact(get);
// run
const get_cmd = b.addRunArtifact(get);
if (b.args) |args| {
get_cmd.addArgs(args);
}
// step
const get_step = b.step("get", "request URL");
get_step.dependOn(&get_cmd.step);
}
fn common(
b: *std.Build,
step: *std.Build.Step.Compile,
options: jsruntime.Options,
) !void {
const jsruntimemod = try jsruntime_pkgs.module(
b,
options,
step.root_module.optimize.?,
step.root_module.resolved_target.?,
var opts = b.addOptions();
opts.addOption(
[]const u8,
"git_commit",
b.option([]const u8, "git_commit", "Current git commit") orelse "dev",
);
step.root_module.addImport("jsruntime", jsruntimemod);
const netsurf = moduleNetSurf(b);
netsurf.addImport("jsruntime", jsruntimemod);
step.root_module.addImport("netsurf", netsurf);
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const tlsmod = b.addModule("tls", .{
.root_source_file = b.path("vendor/tls.zig/src/main.zig"),
});
step.root_module.addImport("tls", tlsmod);
{
// browser
// -------
// compile and install
const exe = b.addExecutable(.{
.name = "lightpanda",
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/main.zig"),
});
try common(b, opts, exe);
b.installArtifact(exe);
// run
const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| {
run_cmd.addArgs(args);
}
// step
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
}
{
// get v8
// -------
const v8 = b.dependency("v8", .{ .target = target, .optimize = optimize });
const get_v8 = b.addRunArtifact(v8.artifact("get-v8"));
const get_step = b.step("get-v8", "Get v8");
get_step.dependOn(&get_v8.step);
}
{
// build v8
// -------
const v8 = b.dependency("v8", .{ .target = target, .optimize = optimize });
const build_v8 = b.addRunArtifact(v8.artifact("build-v8"));
const build_step = b.step("build-v8", "Build v8");
build_step.dependOn(&build_v8.step);
}
{
// tests
// ----
// compile
const tests = b.addTest(.{
.root_source_file = b.path("src/main.zig"),
.test_runner = .{ .path = b.path("src/test_runner.zig"), .mode = .simple },
.target = target,
.optimize = optimize,
});
try common(b, opts, tests);
const run_tests = b.addRunArtifact(tests);
if (b.args) |args| {
run_tests.addArgs(args);
}
// step
const tests_step = b.step("test", "Run unit tests");
tests_step.dependOn(&run_tests.step);
}
{
// wpt
// -----
// compile and install
const wpt = b.addExecutable(.{
.name = "lightpanda-wpt",
.root_source_file = b.path("src/main_wpt.zig"),
.target = target,
.optimize = optimize,
});
try common(b, opts, wpt);
// run
const wpt_cmd = b.addRunArtifact(wpt);
if (b.args) |args| {
wpt_cmd.addArgs(args);
}
// step
const wpt_step = b.step("wpt", "WPT tests");
wpt_step.dependOn(&wpt_cmd.step);
}
}
fn moduleNetSurf(b: *std.Build) *std.Build.Module {
const mod = b.addModule("netsurf", .{
.root_source_file = b.path("src/netsurf/netsurf.zig"),
});
// iconv
mod.addObjectFile(b.path("vendor/libiconv/lib/libiconv.a"));
mod.addIncludePath(b.path("vendor/libiconv/include"));
fn common(b: *std.Build, opts: *std.Build.Step.Options, step: *std.Build.Step.Compile) !void {
const mod = step.root_module;
const target = mod.resolved_target.?;
const optimize = mod.optimize.?;
const dep_opts = .{ .target = target, .optimize = optimize };
// mimalloc
mod.addImport("mimalloc", moduleMimalloc(b));
try moduleNetSurf(b, step, target);
mod.addImport("tls", b.dependency("tls", dep_opts).module("tls"));
mod.addImport("tigerbeetle-io", b.dependency("tigerbeetle_io", .{}).module("tigerbeetle_io"));
{
// v8
const v8_opts = b.addOptions();
v8_opts.addOption(bool, "inspector_subtype", false);
const v8_mod = b.dependency("v8", dep_opts).module("v8");
v8_mod.addOptions("default_exports", v8_opts);
mod.addImport("v8", v8_mod);
}
const mode_str: []const u8 = if (mod.optimize.? == .Debug) "debug" else "release";
// FIXME: we are tied to native v8 builds, currently:
// - aarch64-macos
// - x86_64-linux
const os = target.result.os.tag;
const arch = target.result.cpu.arch;
switch (os) {
.macos => {},
.linux => {
// TODO: why do we need it? It should be linked already when we built v8
mod.link_libcpp = true;
},
else => return error.OsNotSupported,
}
const lib_path = try std.fmt.allocPrint(
mod.owner.allocator,
"v8/build/{s}-{s}/{s}/ninja/obj/zig/libc_v8.a",
.{ @tagName(arch), @tagName(os), mode_str },
);
mod.addObjectFile(mod.owner.path(lib_path));
mod.addImport("build_info", opts.createModule());
}
fn moduleNetSurf(b: *std.Build, step: *std.Build.Step.Compile, target: std.Build.ResolvedTarget) !void {
const os = target.result.os.tag;
const arch = target.result.cpu.arch;
// iconv
const libiconv_lib_path = try std.fmt.allocPrint(
b.allocator,
"vendor/libiconv/out/{s}-{s}/lib/libiconv.a",
.{ @tagName(os), @tagName(arch) },
);
const libiconv_include_path = try std.fmt.allocPrint(
b.allocator,
"vendor/libiconv/out/{s}-{s}/lib/libiconv.a",
.{ @tagName(os), @tagName(arch) },
);
step.addObjectFile(b.path(libiconv_lib_path));
step.addIncludePath(b.path(libiconv_include_path));
{
// mimalloc
const mimalloc = "vendor/mimalloc";
const lib_path = try std.fmt.allocPrint(
b.allocator,
mimalloc ++ "/out/{s}-{s}/lib/libmimalloc.a",
.{ @tagName(os), @tagName(arch) },
);
step.addObjectFile(b.path(lib_path));
step.addIncludePath(b.path(mimalloc ++ "/include"));
}
// netsurf libs
const ns = "vendor/netsurf";
mod.addIncludePath(b.path(ns ++ "/include"));
const ns_include_path = try std.fmt.allocPrint(
b.allocator,
ns ++ "/out/{s}-{s}/include",
.{ @tagName(os), @tagName(arch) },
);
step.addIncludePath(b.path(ns_include_path));
const libs: [4][]const u8 = .{
"libdom",
@@ -220,20 +229,12 @@ fn moduleNetSurf(b: *std.Build) *std.Build.Module {
"libwapcaplet",
};
inline for (libs) |lib| {
mod.addObjectFile(b.path(ns ++ "/lib/" ++ lib ++ ".a"));
mod.addIncludePath(b.path(ns ++ "/" ++ lib ++ "/src"));
const ns_lib_path = try std.fmt.allocPrint(
b.allocator,
ns ++ "/out/{s}-{s}/lib/" ++ lib ++ ".a",
.{ @tagName(os), @tagName(arch) },
);
step.addObjectFile(b.path(ns_lib_path));
step.addIncludePath(b.path(ns ++ "/" ++ lib ++ "/src"));
}
return mod;
}
fn moduleMimalloc(b: *std.Build) *std.Build.Module {
const mod = b.addModule("mimalloc", .{
.root_source_file = b.path("src/mimalloc/mimalloc.zig"),
});
mod.addObjectFile(b.path("vendor/mimalloc/out/libmimalloc.a"));
mod.addIncludePath(b.path("vendor/mimalloc/out/include"));
return mod;
}

22
build.zig.zon Normal file
View File

@@ -0,0 +1,22 @@
.{
.name = .browser,
.paths = .{""},
.version = "0.0.0",
.fingerprint = 0xda130f3af836cea0,
.dependencies = .{
.tls = .{
.url = "https://github.com/ianic/tls.zig/archive/b29a8b45fc59fc2d202769c4f54509bb9e17d0a2.tar.gz",
.hash = "tls-0.1.0-ER2e0uAxBQDm_TmSDdbiiyvAZoh4ejlDD4hW8Fl813xE",
},
.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/f0c7eaaffe39f2f1a224fbe97e550daca0ca1801.tar.gz",
.hash = "v8-0.0.0-xddH62T4IADchAHFgo4nx79w1VedNDhIVErtSNgup-Tk",
},
//.v8 = .{ .path = "../zig-v8-fork" },
//.tigerbeetle_io = .{ .path = "../tigerbeetle-io" },
},
}

202
flake.lock generated Normal file
View File

@@ -0,0 +1,202 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_3": {
"inputs": {
"systems": "systems_3"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"iguana": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": [
"nixpkgs"
],
"zigPkgs": "zigPkgs"
},
"locked": {
"lastModified": 1746539192,
"narHash": "sha256-32nN8JlRqNuCFfrDooyre+gDSnxZuCtK/qaHhRmGMhg=",
"owner": "mookums",
"repo": "iguana",
"rev": "5569f95694edf59803429400ff6cb1c7522da801",
"type": "github"
},
"original": {
"owner": "mookums",
"repo": "iguana",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1746397377,
"narHash": "sha256-5oLdRa3vWSRbuqPIFFmQBGGUqaYZBxX+GGtN9f/n4lU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ed30f8aba41605e3ab46421e3dcb4510ec560ff8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1746481231,
"narHash": "sha256-U3VKPi5D2oLBFzaMI0jJLJp8J64ZLjz+EwodUS//QWc=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "c6aca34d2ca2ce9e20b722f54e684cda64b275c2",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "release-24.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"iguana": "iguana",
"nixpkgs": "nixpkgs_2"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_3": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"zigPkgs": {
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": "flake-utils_3",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1746475050,
"narHash": "sha256-KJC7BNY+NPCc1I+quGkWtoHXOMvFVEyer8Y0haOtTCA=",
"owner": "mookums",
"repo": "zig-overlay",
"rev": "dfa488aa462932e46f44fddf6677ff22f1244c22",
"type": "github"
},
"original": {
"owner": "mookums",
"repo": "zig-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

62
flake.nix Normal file
View File

@@ -0,0 +1,62 @@
{
description = "headless browser designed for AI and automation";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/release-24.11";
iguana.url = "github:mookums/iguana";
iguana.inputs.nixpkgs.follows = "nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
nixpkgs,
iguana,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
zigVersion = "0_14_0";
iguanaLib = iguana.lib.${system};
pkgs = import nixpkgs {
inherit system;
overlays = [
(iguanaLib.mkZigOverlay zigVersion)
(iguanaLib.mkZlsOverlay zigVersion)
];
};
# This build pipeline is very unhappy without an FHS-compliant env.
fhs = pkgs.buildFHSUserEnv {
name = "fhs-shell";
targetPkgs =
pkgs: with pkgs; [
zig
zls
pkg-config
cmake
gperf
expat.dev
python3
glib.dev
glibc.dev
zlib
ninja
gn
gcc-unwrapped
binutils
clang
clang-tools
];
};
in
{
devShells.default = fhs.env;
}
);
}

99
src/app.zig Normal file
View File

@@ -0,0 +1,99 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const Loop = @import("runtime/loop.zig").Loop;
const HttpClient = @import("http/client.zig").Client;
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
const Notification = @import("notification.zig").Notification;
const log = std.log.scoped(.app);
// Container for global state / objects that various parts of the system
// might need.
pub const App = struct {
loop: *Loop,
config: Config,
allocator: Allocator,
telemetry: Telemetry,
http_client: HttpClient,
app_dir_path: ?[]const u8,
notification: *Notification,
pub const RunMode = enum {
help,
fetch,
serve,
version,
};
pub const Config = struct {
run_mode: RunMode,
gc_hints: bool = false,
tls_verify_host: bool = true,
};
pub fn init(allocator: Allocator, config: Config) !*App {
const app = try allocator.create(App);
errdefer allocator.destroy(app);
const loop = try allocator.create(Loop);
errdefer allocator.destroy(loop);
loop.* = try Loop.init(allocator);
errdefer loop.deinit();
const notification = try Notification.init(allocator, null);
errdefer notification.deinit();
const app_dir_path = getAndMakeAppDir(allocator);
app.* = .{
.loop = loop,
.allocator = allocator,
.telemetry = undefined,
.app_dir_path = app_dir_path,
.notification = notification,
.http_client = try HttpClient.init(allocator, 5, .{
.tls_verify_host = config.tls_verify_host,
}),
.config = config,
};
app.telemetry = Telemetry.init(app, config.run_mode);
try app.telemetry.register(app.notification);
return app;
}
pub fn deinit(self: *App) void {
const allocator = self.allocator;
if (self.app_dir_path) |app_dir_path| {
allocator.free(app_dir_path);
}
self.telemetry.deinit();
self.loop.deinit();
allocator.destroy(self.loop);
self.http_client.deinit();
self.notification.deinit();
allocator.destroy(self);
}
};
fn getAndMakeAppDir(allocator: Allocator) ?[]const u8 {
if (@import("builtin").is_test) {
return allocator.dupe(u8, "/tmp") catch unreachable;
}
const app_dir_path = std.fs.getAppDataDir(allocator, "lightpanda") catch |err| {
log.warn("failed to get lightpanda data dir: {}", .{err});
return null;
};
std.fs.cwd().makePath(app_dir_path) catch |err| switch (err) {
error.PathAlreadyExists => return app_dir_path,
else => {
allocator.free(app_dir_path);
log.warn("failed to create lightpanda data dir: {}", .{err});
return null;
},
};
return app_dir_path;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,133 +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 builtin = @import("builtin");
const posix = std.posix;
const io = std.io;
const assert = std.debug.assert;
const tcp = @import("tcp.zig");
pub const Stream = struct {
alloc: std.mem.Allocator,
conn: *tcp.Conn,
handle: posix.socket_t,
pub fn close(self: Stream) void {
posix.close(self.handle);
self.alloc.destroy(self.conn);
}
pub const ReadError = posix.ReadError;
pub const WriteError = posix.WriteError;
pub const Reader = io.Reader(Stream, ReadError, read);
pub const Writer = io.Writer(Stream, WriteError, write);
pub fn reader(self: Stream) Reader {
return .{ .context = self };
}
pub fn writer(self: Stream) Writer {
return .{ .context = self };
}
pub fn read(self: Stream, buffer: []u8) ReadError!usize {
return self.conn.receive(self.handle, buffer) catch |err| switch (err) {
else => return error.Unexpected,
};
}
pub fn readv(s: Stream, iovecs: []const posix.iovec) ReadError!usize {
return posix.readv(s.handle, iovecs);
}
/// Returns the number of bytes read. If the number read is smaller than
/// `buffer.len`, it means the stream reached the end. Reaching the end of
/// a stream is not an error condition.
pub fn readAll(s: Stream, buffer: []u8) ReadError!usize {
return readAtLeast(s, buffer, buffer.len);
}
/// Returns the number of bytes read, calling the underlying read function
/// the minimal number of times until the buffer has at least `len` bytes
/// filled. If the number read is less than `len` it means the stream
/// reached the end. Reaching the end of the stream is not an error
/// condition.
pub fn readAtLeast(s: Stream, buffer: []u8, len: usize) ReadError!usize {
assert(len <= buffer.len);
var index: usize = 0;
while (index < len) {
const amt = try s.read(buffer[index..]);
if (amt == 0) break;
index += amt;
}
return index;
}
/// TODO in evented I/O mode, this implementation incorrectly uses the event loop's
/// file system thread instead of non-blocking. It needs to be reworked to properly
/// use non-blocking I/O.
pub fn write(self: Stream, buffer: []const u8) WriteError!usize {
return self.conn.send(self.handle, buffer) catch |err| switch (err) {
error.AccessDenied => error.AccessDenied,
error.WouldBlock => error.WouldBlock,
error.ConnectionResetByPeer => error.ConnectionResetByPeer,
error.MessageTooBig => error.FileTooBig,
error.BrokenPipe => error.BrokenPipe,
else => return error.Unexpected,
};
}
pub fn writeAll(self: Stream, bytes: []const u8) WriteError!void {
var index: usize = 0;
while (index < bytes.len) {
index += try self.write(bytes[index..]);
}
}
/// See https://github.com/ziglang/zig/issues/7699
/// See equivalent function: `std.fs.File.writev`.
pub fn writev(self: Stream, iovecs: []const posix.iovec_const) WriteError!usize {
if (iovecs.len == 0) return 0;
const first_buffer = iovecs[0].base[0..iovecs[0].len];
return try self.write(first_buffer);
}
/// The `iovecs` parameter is mutable because this function needs to mutate the fields in
/// order to handle partial writes from the underlying OS layer.
/// See https://github.com/ziglang/zig/issues/7699
/// See equivalent function: `std.fs.File.writevAll`.
pub fn writevAll(self: Stream, iovecs: []posix.iovec_const) WriteError!void {
if (iovecs.len == 0) return;
var i: usize = 0;
while (true) {
var amt = try self.writev(iovecs[i..]);
while (amt >= iovecs[i].len) {
amt -= iovecs[i].len;
i += 1;
if (i >= iovecs.len) return;
}
iovecs[i].base += amt;
iovecs[i].len -= amt;
}
}
};

View File

@@ -1,112 +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 net = std.net;
const Stream = @import("stream.zig").Stream;
const Loop = @import("jsruntime").Loop;
const NetworkImpl = Loop.Network(Conn.Command);
// Conn is a TCP connection using jsruntime Loop async I/O.
// connect, send and receive are blocking, but use async I/O in the background.
// Client doesn't own the socket used for the connection, the caller is
// responsible for closing it.
pub const Conn = struct {
const Command = struct {
impl: NetworkImpl,
done: bool = false,
err: ?anyerror = null,
ln: usize = 0,
fn ok(self: *Command, err: ?anyerror, ln: usize) void {
self.err = err;
self.ln = ln;
self.done = true;
}
fn wait(self: *Command) !usize {
while (!self.done) try self.impl.tick();
if (self.err) |err| return err;
return self.ln;
}
pub fn onConnect(self: *Command, err: ?anyerror) void {
self.ok(err, 0);
}
pub fn onSend(self: *Command, ln: usize, err: ?anyerror) void {
self.ok(err, ln);
}
pub fn onReceive(self: *Command, ln: usize, err: ?anyerror) void {
self.ok(err, ln);
}
};
loop: *Loop,
pub fn connect(self: *Conn, socket: std.posix.socket_t, address: std.net.Address) !void {
var cmd = Command{ .impl = NetworkImpl.init(self.loop) };
cmd.impl.connect(&cmd, socket, address);
_ = try cmd.wait();
}
pub fn send(self: *Conn, socket: std.posix.socket_t, buffer: []const u8) !usize {
var cmd = Command{ .impl = NetworkImpl.init(self.loop) };
cmd.impl.send(&cmd, socket, buffer);
return try cmd.wait();
}
pub fn receive(self: *Conn, socket: std.posix.socket_t, buffer: []u8) !usize {
var cmd = Command{ .impl = NetworkImpl.init(self.loop) };
cmd.impl.receive(&cmd, socket, buffer);
return try cmd.wait();
}
};
pub fn tcpConnectToHost(alloc: std.mem.Allocator, loop: *Loop, name: []const u8, port: u16) !Stream {
// TODO async resolve
const list = try net.getAddressList(alloc, name, port);
defer list.deinit();
if (list.addrs.len == 0) return error.UnknownHostName;
for (list.addrs) |addr| {
return tcpConnectToAddress(alloc, loop, addr) catch |err| switch (err) {
error.ConnectionRefused => {
continue;
},
else => return err,
};
}
return std.posix.ConnectError.ConnectionRefused;
}
pub fn tcpConnectToAddress(alloc: std.mem.Allocator, loop: *Loop, addr: net.Address) !Stream {
const sockfd = try std.posix.socket(addr.any.family, std.posix.SOCK.STREAM, std.posix.IPPROTO.TCP);
errdefer std.posix.close(sockfd);
var conn = try alloc.create(Conn);
conn.* = Conn{ .loop = loop };
try conn.connect(sockfd, addr);
return Stream{
.alloc = alloc,
.conn = conn,
.handle = sockfd,
};
}

View File

@@ -1,189 +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 http = std.http;
const Client = @import("Client.zig");
const Request = @import("Client.zig").Request;
pub const Loop = @import("jsruntime").Loop;
const url = "https://w3.org";
test "blocking mode fetch API" {
const alloc = std.testing.allocator;
var loop = try Loop.init(alloc);
defer loop.deinit();
var client: Client = .{
.allocator = alloc,
.loop = &loop,
};
defer client.deinit();
// force client's CA cert scan from system.
try client.ca_bundle.rescan(client.allocator);
const res = try client.fetch(.{
.location = .{ .uri = try std.Uri.parse(url) },
});
try std.testing.expect(res.status == .ok);
}
test "blocking mode open/send/wait API" {
const alloc = std.testing.allocator;
var loop = try Loop.init(alloc);
defer loop.deinit();
var client: Client = .{
.allocator = alloc,
.loop = &loop,
};
defer client.deinit();
// force client's CA cert scan from system.
try client.ca_bundle.rescan(client.allocator);
var buf: [2014]u8 = undefined;
var req = try client.open(.GET, try std.Uri.parse(url), .{
.server_header_buffer = &buf,
});
defer req.deinit();
try req.send();
try req.finish();
try req.wait();
try std.testing.expect(req.response.status == .ok);
}
// Example how to write an async http client using the modified standard client.
const AsyncClient = struct {
cli: Client,
const YieldImpl = Loop.Yield(AsyncRequest);
const AsyncRequest = struct {
const State = enum { new, open, send, finish, wait, done };
cli: *Client,
uri: std.Uri,
req: ?Request = undefined,
state: State = .new,
impl: YieldImpl,
err: ?anyerror = null,
buf: [2014]u8 = undefined,
pub fn deinit(self: *AsyncRequest) void {
if (self.req) |*r| r.deinit();
}
pub fn fetch(self: *AsyncRequest) void {
self.state = .new;
return self.impl.yield(self);
}
fn onerr(self: *AsyncRequest, err: anyerror) void {
self.state = .done;
self.err = err;
}
pub fn onYield(self: *AsyncRequest, err: ?anyerror) void {
if (err) |e| return self.onerr(e);
switch (self.state) {
.new => {
self.state = .open;
self.req = self.cli.open(.GET, self.uri, .{
.server_header_buffer = &self.buf,
}) catch |e| return self.onerr(e);
},
.open => {
self.state = .send;
self.req.?.send() catch |e| return self.onerr(e);
},
.send => {
self.state = .finish;
self.req.?.finish() catch |e| return self.onerr(e);
},
.finish => {
self.state = .wait;
self.req.?.wait() catch |e| return self.onerr(e);
},
.wait => {
self.state = .done;
return;
},
.done => return,
}
return self.impl.yield(self);
}
pub fn wait(self: *AsyncRequest) !void {
while (self.state != .done) try self.impl.tick();
if (self.err) |err| return err;
}
};
pub fn init(alloc: std.mem.Allocator, loop: *Loop) AsyncClient {
return .{
.cli = .{
.allocator = alloc,
.loop = loop,
},
};
}
pub fn deinit(self: *AsyncClient) void {
self.cli.deinit();
}
pub fn createRequest(self: *AsyncClient, uri: std.Uri) !AsyncRequest {
return .{
.impl = YieldImpl.init(self.cli.loop),
.cli = &self.cli,
.uri = uri,
};
}
};
test "non blocking client" {
const alloc = std.testing.allocator;
var loop = try Loop.init(alloc);
defer loop.deinit();
var client = AsyncClient.init(alloc, &loop);
defer client.deinit();
var reqs: [3]AsyncClient.AsyncRequest = undefined;
for (0..reqs.len) |i| {
reqs[i] = try client.createRequest(try std.Uri.parse(url));
reqs[i].fetch();
}
for (0..reqs.len) |i| {
try reqs[i].wait();
reqs[i].deinit();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,240 @@
// 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 builtin = @import("builtin");
const JsObject = @import("../env.zig").Env.JsObject;
const SessionState = @import("../env.zig").SessionState;
const log = if (builtin.is_test) &test_capture else std.log.scoped(.console);
pub const Console = struct {
// TODO: configurable writer
timers: std.StringHashMapUnmanaged(u32) = .{},
counts: std.StringHashMapUnmanaged(u32) = .{},
pub fn _log(_: *const Console, values: []JsObject, state: *SessionState) !void {
if (values.len == 0) {
return;
}
log.info("{s}", .{try serializeValues(values, state)});
}
pub fn _info(console: *const Console, values: []JsObject, state: *SessionState) !void {
return console._log(values, state);
}
pub fn _debug(_: *const Console, values: []JsObject, state: *SessionState) !void {
if (values.len == 0) {
return;
}
log.debug("{s}", .{try serializeValues(values, state)});
}
pub fn _warn(_: *const Console, values: []JsObject, state: *SessionState) !void {
if (values.len == 0) {
return;
}
log.warn("{s}", .{try serializeValues(values, state)});
}
pub fn _error(_: *const Console, values: []JsObject, state: *SessionState) !void {
if (values.len == 0) {
return;
}
log.err("{s}", .{try serializeValues(values, state)});
}
pub fn _clear(_: *const Console) void {}
pub fn _count(self: *Console, label_: ?[]const u8, state: *SessionState) !void {
const label = label_ orelse "default";
const gop = try self.counts.getOrPut(state.arena, label);
var current: u32 = 0;
if (gop.found_existing) {
current = gop.value_ptr.*;
} else {
gop.key_ptr.* = try state.arena.dupe(u8, label);
}
const count = current + 1;
gop.value_ptr.* = count;
log.info("{s}: {d}", .{ label, count });
}
pub fn _countReset(self: *Console, label_: ?[]const u8) !void {
const label = label_ orelse "default";
const kv = self.counts.fetchRemove(label) orelse {
log.warn("Counter \"{s}\" doesn't exist.", .{label});
return;
};
log.info("{s}: {d}", .{ label, kv.value });
}
pub fn _time(self: *Console, label_: ?[]const u8, state: *SessionState) !void {
const label = label_ orelse "default";
const gop = try self.timers.getOrPut(state.arena, label);
if (gop.found_existing) {
log.warn("Timer \"{s}\" already exists.", .{label});
return;
}
gop.key_ptr.* = try state.arena.dupe(u8, label);
gop.value_ptr.* = timestamp();
}
pub fn _timeLog(self: *Console, label_: ?[]const u8) void {
const elapsed = timestamp();
const label = label_ orelse "default";
const start = self.timers.get(label) orelse {
log.warn("Timer \"{s}\" doesn't exist.", .{label});
return;
};
log.info("\"{s}\": {d}ms", .{ label, elapsed - start });
}
pub fn _timeStop(self: *Console, label_: ?[]const u8) void {
const elapsed = timestamp();
const label = label_ orelse "default";
const kv = self.timers.fetchRemove(label) orelse {
log.warn("Timer \"{s}\" doesn't exist.", .{label});
return;
};
log.info("\"{s}\": {d}ms - timer ended", .{ label, elapsed - kv.value });
}
pub fn _assert(_: *Console, assertion: JsObject, values: []JsObject, state: *SessionState) !void {
if (assertion.isTruthy()) {
return;
}
var serialized_values: []const u8 = "";
if (values.len > 0) {
serialized_values = try serializeValues(values, state);
}
log.err("Assertion failed: {s}", .{serialized_values});
}
fn serializeValues(values: []JsObject, state: *SessionState) ![]const u8 {
const arena = state.call_arena;
var arr: std.ArrayListUnmanaged(u8) = .{};
try arr.appendSlice(arena, try values[0].toString());
for (values[1..]) |value| {
try arr.append(arena, ' ');
try arr.appendSlice(arena, try value.toString());
}
return arr.items;
}
};
fn timestamp() u32 {
const ts = std.posix.clock_gettime(std.posix.CLOCK.MONOTONIC) catch unreachable;
return @intCast(ts.sec);
}
var test_capture = TestCapture{};
const testing = @import("../../testing.zig");
test "Browser.Console" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
defer testing.reset();
{
try runner.testCases(&.{
.{ "console.log('a')", "undefined" },
.{ "console.warn('hello world', 23, true, new Object())", "undefined" },
}, .{});
const captured = test_capture.captured.items;
try testing.expectEqual("a", captured[0]);
try testing.expectEqual("hello world 23 true [object Object]", captured[1]);
}
{
test_capture.reset();
try runner.testCases(&.{
.{ "console.countReset()", "undefined" },
.{ "console.count()", "undefined" },
.{ "console.count('teg')", "undefined" },
.{ "console.count('teg')", "undefined" },
.{ "console.count('teg')", "undefined" },
.{ "console.count()", "undefined" },
.{ "console.countReset('teg')", "undefined" },
.{ "console.countReset()", "undefined" },
.{ "console.count()", "undefined" },
}, .{});
const captured = test_capture.captured.items;
try testing.expectEqual("Counter \"default\" doesn't exist.", captured[0]);
try testing.expectEqual("default: 1", captured[1]);
try testing.expectEqual("teg: 1", captured[2]);
try testing.expectEqual("teg: 2", captured[3]);
try testing.expectEqual("teg: 3", captured[4]);
try testing.expectEqual("default: 2", captured[5]);
try testing.expectEqual("teg: 3", captured[6]);
try testing.expectEqual("default: 2", captured[7]);
try testing.expectEqual("default: 1", captured[8]);
}
{
test_capture.reset();
try runner.testCases(&.{
.{ "console.assert(true)", "undefined" },
.{ "console.assert('a', 2, 3, 4)", "undefined" },
.{ "console.assert('')", "undefined" },
.{ "console.assert('', 'x', true)", "undefined" },
.{ "console.assert(false, 'x')", "undefined" },
}, .{});
const captured = test_capture.captured.items;
try testing.expectEqual("Assertion failed: ", captured[0]);
try testing.expectEqual("Assertion failed: x true", captured[1]);
try testing.expectEqual("Assertion failed: x", captured[2]);
}
}
const TestCapture = struct {
captured: std.ArrayListUnmanaged([]const u8) = .{},
fn reset(self: *TestCapture) void {
self.captured = .{};
}
fn debug(self: *TestCapture, comptime fmt: []const u8, args: anytype) void {
const str = std.fmt.allocPrint(testing.arena_allocator, fmt, args) catch unreachable;
self.captured.append(testing.arena_allocator, str) catch unreachable;
}
fn info(self: *TestCapture, comptime fmt: []const u8, args: anytype) void {
self.debug(fmt, args);
}
fn warn(self: *TestCapture, comptime fmt: []const u8, args: anytype) void {
self.debug(fmt, args);
}
fn err(self: *TestCapture, comptime fmt: []const u8, args: anytype) void {
self.debug(fmt, args);
}
};

View File

@@ -0,0 +1,82 @@
// 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 uuidv4 = @import("../../id.zig").uuidv4;
// https://w3c.github.io/webcrypto/#crypto-interface
pub const Crypto = struct {
pub fn _getRandomValues(_: *const Crypto, into: RandomValues) !void {
const buf = into.asBuffer();
if (buf.len > 65_536) {
return error.QuotaExceededError;
}
std.crypto.random.bytes(buf);
}
pub fn _randomUUID(_: *const Crypto) [36]u8 {
var hex: [36]u8 = undefined;
uuidv4(&hex);
return hex;
}
};
const RandomValues = union(enum) {
int8: []i8,
uint8: []u8,
int16: []i16,
uint16: []u16,
int32: []i32,
uint32: []u32,
int64: []i64,
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],
}
}
};
const testing = @import("../../testing.zig");
test "Browser.Crypto" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "const a = crypto.randomUUID();", "undefined" },
.{ "const b = crypto.randomUUID();", "undefined" },
.{ "a.length;", "36" },
.{ "a.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" },
.{ "new Set(r1).size", "5" },
}, .{});
}

View File

@@ -18,7 +18,7 @@
const std = @import("std");
const parser = @import("netsurf");
const parser = @import("../netsurf.zig");
// Node implementation with Netsurf Libdom C lib.
pub const Node = struct {

View File

@@ -19,7 +19,7 @@
const std = @import("std");
const css = @import("css.zig");
const Node = @import("libdom.zig").Node;
const parser = @import("netsurf");
const parser = @import("../netsurf.zig");
const Matcher = struct {
const Nodes = std.ArrayList(Node);

79
src/browser/datauri.zig Normal file
View File

@@ -0,0 +1,79 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
// Represents https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data
pub const DataURI = struct {
was_base64_encoded: bool,
// The contents in the uri. It will be base64 decoded but not prepared in
// any way for mime.charset.
data: []const u8,
// Parses data:[<media-type>][;base64],<data>
pub fn parse(allocator: Allocator, src: []const u8) !?DataURI {
if (!std.mem.startsWith(u8, src, "data:")) {
return null;
}
const uri = src[5..];
const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null;
// Extract the encoding.
var metadata = uri[0..data_starts];
var base64_encoded = false;
if (std.mem.endsWith(u8, metadata, ";base64")) {
base64_encoded = true;
metadata = metadata[0 .. metadata.len - 7];
}
// TODO: Extract mime type. This not trivial because Mime.parse requires
// a []u8 and might mutate the src. And, the DataURI.parse references atm
// do not have deinit calls.
// Prepare the data.
var data = uri[data_starts + 1 ..];
if (base64_encoded) {
const decoder = std.base64.standard.Decoder;
const decoded_size = try decoder.calcSizeForSlice(data);
const buffer = try allocator.alloc(u8, decoded_size);
errdefer allocator.free(buffer);
try decoder.decode(buffer, data);
data = buffer;
}
return .{
.was_base64_encoded = base64_encoded,
.data = data,
};
}
pub fn deinit(self: *const DataURI, allocator: Allocator) void {
if (self.was_base64_encoded) {
allocator.free(self.data);
}
}
};
const testing = std.testing;
test "DataURI: parse valid" {
try test_valid("data:text/javascript; charset=utf-8;base64,Zm9v", "foo");
try test_valid("data:text/javascript; charset=utf-8;,foo", "foo");
try test_valid("data:,foo", "foo");
}
test "DataURI: parse invalid" {
try test_cannot_parse("atad:,foo");
try test_cannot_parse("data:foo");
try test_cannot_parse("data:");
}
fn test_valid(uri: []const u8, expected: []const u8) !void {
const data_uri = try DataURI.parse(std.testing.allocator, uri) orelse return error.TestFailed;
defer data_uri.deinit(testing.allocator);
try testing.expectEqualStrings(expected, data_uri.data);
}
fn test_cannot_parse(uri: []const u8) !void {
try testing.expectEqual(null, DataURI.parse(std.testing.allocator, uri));
}

View File

@@ -16,22 +16,15 @@
// 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 jsruntime = @import("jsruntime");
const Case = jsruntime.test_utils.Case;
const checkCases = jsruntime.test_utils.checkCases;
const parser = @import("netsurf");
const parser = @import("../netsurf.zig");
const Node = @import("node.zig").Node;
const DOMException = @import("exceptions.zig").DOMException;
// WEB IDL https://dom.spec.whatwg.org/#attr
pub const Attr = struct {
pub const Self = parser.Attribute;
pub const prototype = *Node;
pub const mem_guarantied = true;
pub const subtype = .node;
pub fn get_namespaceURI(self: *parser.Attribute) !?[]const u8 {
return try parser.nodeGetNamespace(parser.attributeToNode(self));
@@ -70,34 +63,33 @@ pub const Attr = struct {
// Tests
// -----
pub fn testExecFn(
_: std.mem.Allocator,
js_env: *jsruntime.Env,
) anyerror!void {
var getters = [_]Case{
.{ .src = "let a = document.createAttributeNS('foo', 'bar')", .ex = "undefined" },
.{ .src = "a.namespaceURI", .ex = "foo" },
.{ .src = "a.prefix", .ex = "null" },
.{ .src = "a.localName", .ex = "bar" },
.{ .src = "a.name", .ex = "bar" },
.{ .src = "a.value", .ex = "" },
const testing = @import("../../testing.zig");
test "Browser.DOM.Attribute" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let a = document.createAttributeNS('foo', 'bar')", "undefined" },
.{ "a.namespaceURI", "foo" },
.{ "a.prefix", "null" },
.{ "a.localName", "bar" },
.{ "a.name", "bar" },
.{ "a.value", "" },
// TODO: libdom has a bug here: the created attr has no parent, it
// causes a panic w/ libdom when setting the value.
//.{ .src = "a.value = 'nok'", .ex = "nok" },
.{ .src = "a.ownerElement", .ex = "null" },
};
try checkCases(js_env, &getters);
//.{ "a.value = 'nok'", "nok" },
.{ "a.ownerElement", "null" },
}, .{});
var attr = [_]Case{
.{ .src = "let b = document.getElementById('link').getAttributeNode('class')", .ex = "undefined" },
.{ .src = "b.name", .ex = "class" },
.{ .src = "b.value", .ex = "ok" },
.{ .src = "b.value = 'nok'", .ex = "nok" },
.{ .src = "b.value", .ex = "nok" },
.{ .src = "b.value = null", .ex = "null" },
.{ .src = "b.value", .ex = "null" },
.{ .src = "b.value = 'ok'", .ex = "ok" },
.{ .src = "b.ownerElement.id", .ex = "link" },
};
try checkCases(js_env, &attr);
try runner.testCases(&.{
.{ "let b = document.getElementById('link').getAttributeNode('class')", "undefined" },
.{ "b.name", "class" },
.{ "b.value", "ok" },
.{ "b.value = 'nok'", "nok" },
.{ "b.value", "nok" },
.{ "b.value = null", "null" },
.{ "b.value", "null" },
.{ "b.value = 'ok'", "ok" },
.{ "b.ownerElement.id", "link" },
}, .{});
}

View File

@@ -16,9 +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 parser = @import("netsurf");
const parser = @import("../netsurf.zig");
const Text = @import("text.zig").Text;
@@ -26,5 +24,5 @@ const Text = @import("text.zig").Text;
pub const CDATASection = struct {
pub const Self = parser.CDATASection;
pub const prototype = *Text;
pub const mem_guarantied = true;
pub const subtype = .node;
};

View File

@@ -18,12 +18,7 @@
const std = @import("std");
const jsruntime = @import("jsruntime");
const Case = jsruntime.test_utils.Case;
const checkCases = jsruntime.test_utils.checkCases;
const generate = @import("../generate.zig");
const parser = @import("netsurf");
const parser = @import("../netsurf.zig");
const Node = @import("node.zig").Node;
const Comment = @import("comment.zig").Comment;
@@ -32,18 +27,18 @@ const ProcessingInstruction = @import("processing_instruction.zig").ProcessingIn
const HTMLElem = @import("../html/elements.zig");
// CharacterData interfaces
pub const Interfaces = generate.Tuple(.{
pub const Interfaces = .{
Comment,
Text.Text,
Text.Interfaces,
ProcessingInstruction,
});
};
// CharacterData implementation
pub const CharacterData = struct {
pub const Self = parser.CharacterData;
pub const prototype = *Node;
pub const mem_guarantied = true;
pub const subtype = .node;
// JS funcs
// --------
@@ -102,79 +97,90 @@ pub const CharacterData = struct {
pub fn _substringData(self: *parser.CharacterData, offset: u32, count: u32) ![]const u8 {
return try parser.characterDataSubstringData(self, offset, count);
}
// 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)) {
return false;
}
const other: *parser.CharacterData = @ptrCast(other_node);
if (std.mem.eql(u8, try get_data(self), try get_data(other)) == false) {
return false;
}
return true;
}
};
// Tests
// -----
pub fn testExecFn(
_: std.mem.Allocator,
js_env: *jsruntime.Env,
) anyerror!void {
var get_data = [_]Case{
.{ .src = "let link = document.getElementById('link')", .ex = "undefined" },
.{ .src = "let cdata = link.firstChild", .ex = "undefined" },
.{ .src = "cdata.data", .ex = "OK" },
};
try checkCases(js_env, &get_data);
const testing = @import("../../testing.zig");
test "Browser.DOM.CharacterData" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
var set_data = [_]Case{
.{ .src = "cdata.data = 'OK modified'", .ex = "OK modified" },
.{ .src = "cdata.data === 'OK modified'", .ex = "true" },
.{ .src = "cdata.data = 'OK'", .ex = "OK" },
};
try checkCases(js_env, &set_data);
try runner.testCases(&.{
.{ "let link = document.getElementById('link')", "undefined" },
.{ "let cdata = link.firstChild", "undefined" },
.{ "cdata.data", "OK" },
}, .{});
var get_length = [_]Case{
.{ .src = "cdata.length === 2", .ex = "true" },
};
try checkCases(js_env, &get_length);
try runner.testCases(&.{
.{ "cdata.data = 'OK modified'", "OK modified" },
.{ "cdata.data === 'OK modified'", "true" },
.{ "cdata.data = 'OK'", "OK" },
}, .{});
var get_next_elem_sibling = [_]Case{
.{ .src = "cdata.nextElementSibling === null", .ex = "true" },
try runner.testCases(&.{
.{ "cdata.length === 2", "true" },
}, .{});
try runner.testCases(&.{
.{ "cdata.nextElementSibling === null", "true" },
// create a next element
.{ .src = "let next = document.createElement('a')", .ex = "undefined" },
.{ .src = "link.appendChild(next, cdata) !== undefined", .ex = "true" },
.{ .src = "cdata.nextElementSibling.localName === 'a' ", .ex = "true" },
};
try checkCases(js_env, &get_next_elem_sibling);
.{ "let next = document.createElement('a')", "undefined" },
.{ "link.appendChild(next, cdata) !== undefined", "true" },
.{ "cdata.nextElementSibling.localName === 'a' ", "true" },
}, .{});
var get_prev_elem_sibling = [_]Case{
.{ .src = "cdata.previousElementSibling === null", .ex = "true" },
try runner.testCases(&.{
.{ "cdata.previousElementSibling === null", "true" },
// create a prev element
.{ .src = "let prev = document.createElement('div')", .ex = "undefined" },
.{ .src = "link.insertBefore(prev, cdata) !== undefined", .ex = "true" },
.{ .src = "cdata.previousElementSibling.localName === 'div' ", .ex = "true" },
};
try checkCases(js_env, &get_prev_elem_sibling);
.{ "let prev = document.createElement('div')", "undefined" },
.{ "link.insertBefore(prev, cdata) !== undefined", "true" },
.{ "cdata.previousElementSibling.localName === 'div' ", "true" },
}, .{});
var append_data = [_]Case{
.{ .src = "cdata.appendData(' modified')", .ex = "undefined" },
.{ .src = "cdata.data === 'OK modified' ", .ex = "true" },
};
try checkCases(js_env, &append_data);
try runner.testCases(&.{
.{ "cdata.appendData(' modified')", "undefined" },
.{ "cdata.data === 'OK modified' ", "true" },
}, .{});
var delete_data = [_]Case{
.{ .src = "cdata.deleteData('OK'.length, ' modified'.length)", .ex = "undefined" },
.{ .src = "cdata.data == 'OK'", .ex = "true" },
};
try checkCases(js_env, &delete_data);
try runner.testCases(&.{
.{ "cdata.deleteData('OK'.length, ' modified'.length)", "undefined" },
.{ "cdata.data == 'OK'", "true" },
}, .{});
var insert_data = [_]Case{
.{ .src = "cdata.insertData('OK'.length-1, 'modified')", .ex = "undefined" },
.{ .src = "cdata.data == 'OmodifiedK'", .ex = "true" },
};
try checkCases(js_env, &insert_data);
try runner.testCases(&.{
.{ "cdata.insertData('OK'.length-1, 'modified')", "undefined" },
.{ "cdata.data == 'OmodifiedK'", "true" },
}, .{});
var replace_data = [_]Case{
.{ .src = "cdata.replaceData('OK'.length-1, 'modified'.length, 'replaced')", .ex = "undefined" },
.{ .src = "cdata.data == 'OreplacedK'", .ex = "true" },
};
try checkCases(js_env, &replace_data);
try runner.testCases(&.{
.{ "cdata.replaceData('OK'.length-1, 'modified'.length, 'replaced')", "undefined" },
.{ "cdata.data == 'OreplacedK'", "true" },
}, .{});
var substring_data = [_]Case{
.{ .src = "cdata.substringData('OK'.length-1, 'replaced'.length) == 'replaced'", .ex = "true" },
.{ .src = "cdata.substringData('OK'.length-1, 0) == ''", .ex = "true" },
};
try checkCases(js_env, &substring_data);
try runner.testCases(&.{
.{ "cdata.substringData('OK'.length-1, 'replaced'.length) == 'replaced'", "true" },
.{ "cdata.substringData('OK'.length-1, 0) == ''", "true" },
}, .{});
try runner.testCases(&.{
.{ "cdata.substringData('OK'.length-1, 'replaced'.length) == 'replaced'", "true" },
.{ "cdata.substringData('OK'.length-1, 0) == ''", "true" },
}, .{});
}

View File

@@ -15,27 +15,22 @@
//
// 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");
const jsruntime = @import("jsruntime");
const Case = jsruntime.test_utils.Case;
const checkCases = jsruntime.test_utils.checkCases;
const parser = @import("../netsurf.zig");
const CharacterData = @import("character_data.zig").CharacterData;
const UserContext = @import("../user_context.zig").UserContext;
const SessionState = @import("../env.zig").SessionState;
// https://dom.spec.whatwg.org/#interface-comment
pub const Comment = struct {
pub const Self = parser.Comment;
pub const prototype = *CharacterData;
pub const mem_guarantied = true;
pub const subtype = .node;
pub fn constructor(userctx: UserContext, data: ?[]const u8) !*parser.Comment {
pub fn constructor(data: ?[]const u8, state: *const SessionState) !*parser.Comment {
return parser.documentCreateComment(
parser.documentHTMLToDocument(userctx.document),
parser.documentHTMLToDocument(state.document.?),
data orelse "",
);
}
@@ -44,16 +39,16 @@ pub const Comment = struct {
// Tests
// -----
pub fn testExecFn(
_: std.mem.Allocator,
js_env: *jsruntime.Env,
) anyerror!void {
var constructor = [_]Case{
.{ .src = "let comment = new Comment('foo')", .ex = "undefined" },
.{ .src = "comment.data", .ex = "foo" },
const testing = @import("../../testing.zig");
test "Browser.DOM.Comment" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
.{ .src = "let emptycomment = new Comment()", .ex = "undefined" },
.{ .src = "emptycomment.data", .ex = "" },
};
try checkCases(js_env, &constructor);
try runner.testCases(&.{
.{ "let comment = new Comment('foo')", "undefined" },
.{ "comment.data", "foo" },
.{ "let emptycomment = new Comment()", "undefined" },
.{ "emptycomment.data", "" },
}, .{});
}

View File

@@ -18,7 +18,7 @@
const std = @import("std");
const parser = @import("netsurf");
const parser = @import("../netsurf.zig");
const css = @import("../css/css.zig");
const Node = @import("../css/libdom.zig").Node;
@@ -49,7 +49,7 @@ const MatchAll = struct {
fn init(alloc: std.mem.Allocator) MatchAll {
return .{
.alloc = alloc,
.nl = NodeList.init(),
.nl = .{},
};
}
@@ -62,7 +62,8 @@ const MatchAll = struct {
}
fn toOwnedList(m: *MatchAll) NodeList {
defer m.nl = NodeList.init();
// reset it.
defer m.nl = .{};
return m.nl;
}
};

View File

@@ -0,0 +1,443 @@
// 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 SessionState = @import("../env.zig").SessionState;
const Node = @import("node.zig").Node;
const NodeList = @import("nodelist.zig").NodeList;
const NodeUnion = @import("node.zig").Union;
const collection = @import("html_collection.zig");
const css = @import("css.zig");
const Element = @import("element.zig").Element;
const ElementUnion = @import("element.zig").Union;
const DOMImplementation = @import("implementation.zig").DOMImplementation;
// WEB IDL https://dom.spec.whatwg.org/#document
pub const Document = struct {
pub const Self = parser.Document;
pub const prototype = *Node;
pub const subtype = .node;
pub fn constructor(state: *const SessionState) !*parser.DocumentHTML {
const doc = try parser.documentCreateDocument(
try parser.documentHTMLGetTitle(state.document.?),
);
// we have to work w/ document instead of html document.
const ddoc = parser.documentHTMLToDocument(doc);
const ccur = parser.documentHTMLToDocument(state.document.?);
try parser.documentSetDocumentURI(ddoc, try parser.documentGetDocumentURI(ccur));
try parser.documentSetInputEncoding(ddoc, try parser.documentGetInputEncoding(ccur));
return doc;
}
// JS funcs
// --------
pub fn get_implementation(_: *parser.Document) DOMImplementation {
return DOMImplementation{};
}
pub fn get_documentElement(self: *parser.Document) !?ElementUnion {
const e = try parser.documentGetDocumentElement(self);
if (e == null) return null;
return try Element.toInterface(e.?);
}
pub fn get_documentURI(self: *parser.Document) ![]const u8 {
return try parser.documentGetDocumentURI(self);
}
pub fn get_URL(self: *parser.Document) ![]const u8 {
return try get_documentURI(self);
}
// TODO implement contentType
pub fn get_contentType(self: *parser.Document) []const u8 {
_ = self;
return "text/html";
}
// TODO implement compactMode
pub fn get_compatMode(self: *parser.Document) []const u8 {
_ = self;
return "CSS1Compat";
}
pub fn get_characterSet(self: *parser.Document) ![]const u8 {
return try parser.documentGetInputEncoding(self);
}
// alias of get_characterSet
pub fn get_charset(self: *parser.Document) ![]const u8 {
return try get_characterSet(self);
}
// alias of get_characterSet
pub fn get_inputEncoding(self: *parser.Document) ![]const u8 {
return try get_characterSet(self);
}
pub fn get_doctype(self: *parser.Document) !?*parser.DocumentType {
return try parser.documentGetDoctype(self);
}
pub fn _createEvent(_: *parser.Document, eventCstr: []const u8) !*parser.Event {
// TODO: for now only "Event" constructor is supported
// see table on https://dom.spec.whatwg.org/#dom-document-createevent $2
if (std.ascii.eqlIgnoreCase(eventCstr, "Event") or std.ascii.eqlIgnoreCase(eventCstr, "Events")) {
return try parser.eventCreate();
}
return parser.DOMError.NotSupported;
}
pub fn _getElementById(self: *parser.Document, id: []const u8) !?ElementUnion {
const e = try parser.documentGetElementById(self, id) orelse return null;
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);
}
pub fn _createElementNS(self: *parser.Document, ns: []const u8, tag_name: []const u8) !ElementUnion {
const e = try parser.documentCreateElementNS(self, ns, tag_name);
return try Element.toInterface(e);
}
// We can't simply use libdom dom_document_get_elements_by_tag_name here.
// Indeed, netsurf implemented a previous dom spec when
// getElementsByTagName returned a NodeList.
// But since
// https://github.com/whatwg/dom/commit/190700b7c12ecfd3b5ebdb359ab1d6ea9cbf7749
// the spec changed to return an HTMLCollection instead.
// That's why we reimplemented getElementsByTagName by using an
// HTMLCollection in zig here.
pub fn _getElementsByTagName(
self: *parser.Document,
tag_name: []const u8,
state: *SessionState,
) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(state.arena, parser.documentToNode(self), tag_name, true);
}
pub fn _getElementsByClassName(
self: *parser.Document,
classNames: []const u8,
state: *SessionState,
) !collection.HTMLCollection {
const allocator = state.arena;
return try collection.HTMLCollectionByClassName(allocator, parser.documentToNode(self), classNames, true);
}
pub fn _createDocumentFragment(self: *parser.Document) !*parser.DocumentFragment {
return try parser.documentCreateDocumentFragment(self);
}
pub fn _createTextNode(self: *parser.Document, data: []const u8) !*parser.Text {
return try parser.documentCreateTextNode(self, data);
}
pub fn _createCDATASection(self: *parser.Document, data: []const u8) !*parser.CDATASection {
return try parser.documentCreateCDATASection(self, data);
}
pub fn _createComment(self: *parser.Document, data: []const u8) !*parser.Comment {
return try parser.documentCreateComment(self, data);
}
pub fn _createProcessingInstruction(self: *parser.Document, target: []const u8, data: []const u8) !*parser.ProcessingInstruction {
return try parser.documentCreateProcessingInstruction(self, target, data);
}
pub fn _importNode(self: *parser.Document, node: *parser.Node, deep: ?bool) !NodeUnion {
const n = try parser.documentImportNode(self, node, deep orelse false);
return try Node.toInterface(n);
}
pub fn _adoptNode(self: *parser.Document, node: *parser.Node) !NodeUnion {
const n = try parser.documentAdoptNode(self, node);
return try Node.toInterface(n);
}
pub fn _createAttribute(self: *parser.Document, name: []const u8) !*parser.Attribute {
return try parser.documentCreateAttribute(self, name);
}
pub fn _createAttributeNS(self: *parser.Document, ns: []const u8, qname: []const u8) !*parser.Attribute {
return try parser.documentCreateAttributeNS(self, ns, qname);
}
// ParentNode
// https://dom.spec.whatwg.org/#parentnode
pub fn get_children(self: *parser.Document) !collection.HTMLCollection {
return try collection.HTMLCollectionChildren(parser.documentToNode(self), false);
}
pub fn get_firstElementChild(self: *parser.Document) !?ElementUnion {
const elt = try parser.documentGetDocumentElement(self) orelse return null;
return try Element.toInterface(elt);
}
pub fn get_lastElementChild(self: *parser.Document) !?ElementUnion {
const elt = try parser.documentGetDocumentElement(self) orelse return null;
return try Element.toInterface(elt);
}
pub fn get_childElementCount(self: *parser.Document) !u32 {
_ = try parser.documentGetDocumentElement(self) orelse return 0;
return 1;
}
pub fn _querySelector(self: *parser.Document, selector: []const u8, state: *SessionState) !?ElementUnion {
if (selector.len == 0) return null;
const allocator = state.arena;
const n = try css.querySelector(allocator, parser.documentToNode(self), selector);
if (n == null) return null;
return try Element.toInterface(parser.nodeToElement(n.?));
}
pub fn _querySelectorAll(self: *parser.Document, selector: []const u8, state: *SessionState) !NodeList {
const allocator = state.arena;
return css.querySelectorAll(allocator, parser.documentToNode(self), selector);
}
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
// function must accept either node or string.
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
pub fn _prepend(self: *parser.Document, nodes: []const *parser.Node) !void {
return Node.prepend(parser.documentToNode(self), nodes);
}
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
// function must accept either node or string.
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
pub fn _append(self: *parser.Document, nodes: []const *parser.Node) !void {
return Node.append(parser.documentToNode(self), nodes);
}
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
// function must accept either node or string.
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
pub fn _replaceChildren(self: *parser.Document, nodes: []const *parser.Node) !void {
return Node.replaceChildren(parser.documentToNode(self), nodes);
}
pub fn deinit(_: *parser.Document, _: std.mem.Allocator) void {}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.Document" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "document.__proto__.__proto__.constructor.name", "Document" },
.{ "document.__proto__.__proto__.__proto__.constructor.name", "Node" },
.{ "document.__proto__.__proto__.__proto__.__proto__.constructor.name", "EventTarget" },
.{ "let newdoc = new Document()", "undefined" },
.{ "newdoc.documentElement", "null" },
.{ "newdoc.children.length", "0" },
.{ "newdoc.getElementsByTagName('*').length", "0" },
.{ "newdoc.getElementsByTagName('*').item(0)", "null" },
.{ "newdoc.inputEncoding === document.inputEncoding", "true" },
.{ "newdoc.documentURI === document.documentURI", "true" },
.{ "newdoc.URL === document.URL", "true" },
.{ "newdoc.compatMode === document.compatMode", "true" },
.{ "newdoc.characterSet === document.characterSet", "true" },
.{ "newdoc.charset === document.charset", "true" },
.{ "newdoc.contentType === document.contentType", "true" },
}, .{});
try runner.testCases(&.{
.{ "let getElementById = document.getElementById('content')", "undefined" },
.{ "getElementById.constructor.name", "HTMLDivElement" },
.{ "getElementById.localName", "div" },
}, .{});
try runner.testCases(&.{
.{ "let getElementsByTagName = document.getElementsByTagName('p')", "undefined" },
.{ "getElementsByTagName.length", "2" },
.{ "getElementsByTagName.item(0).localName", "p" },
.{ "getElementsByTagName.item(1).localName", "p" },
.{ "let getElementsByTagNameAll = document.getElementsByTagName('*')", "undefined" },
.{ "getElementsByTagNameAll.length", "8" },
.{ "getElementsByTagNameAll.item(0).localName", "html" },
.{ "getElementsByTagNameAll.item(7).localName", "p" },
.{ "getElementsByTagNameAll.namedItem('para-empty-child').localName", "span" },
}, .{});
try runner.testCases(&.{
.{ "let ok = document.getElementsByClassName('ok')", "undefined" },
.{ "ok.length", "2" },
.{ "let empty = document.getElementsByClassName('empty')", "undefined" },
.{ "empty.length", "1" },
.{ "let emptyok = document.getElementsByClassName('empty ok')", "undefined" },
.{ "emptyok.length", "1" },
}, .{});
try runner.testCases(&.{
.{ "let e = document.documentElement", "undefined" },
.{ "e.localName", "html" },
}, .{});
try runner.testCases(&.{
.{ "document.characterSet", "UTF-8" },
.{ "document.charset", "UTF-8" },
.{ "document.inputEncoding", "UTF-8" },
}, .{});
try runner.testCases(&.{
.{ "document.compatMode", "CSS1Compat" },
}, .{});
try runner.testCases(&.{
.{ "document.contentType", "text/html" },
}, .{});
try runner.testCases(&.{
.{ "document.documentURI", "about:blank" },
.{ "document.URL", "about:blank" },
}, .{});
try runner.testCases(&.{
.{ "let impl = document.implementation", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "let d = new Document()", "undefined" },
.{ "d.characterSet", "UTF-8" },
.{ "d.URL", "about:blank" },
.{ "d.documentURI", "about:blank" },
.{ "d.compatMode", "CSS1Compat" },
.{ "d.contentType", "text/html" },
}, .{});
try runner.testCases(&.{
.{ "var v = document.createDocumentFragment()", "undefined" },
.{ "v.nodeName", "#document-fragment" },
}, .{});
try runner.testCases(&.{
.{ "var v = document.createTextNode('foo')", "undefined" },
.{ "v.nodeName", "#text" },
}, .{});
try runner.testCases(&.{
.{ "var v = document.createCDATASection('foo')", "undefined" },
.{ "v.nodeName", "#cdata-section" },
}, .{});
try runner.testCases(&.{
.{ "var v = document.createComment('foo')", "undefined" },
.{ "v.nodeName", "#comment" },
.{ "let v2 = v.cloneNode()", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "let pi = document.createProcessingInstruction('foo', 'bar')", "undefined" },
.{ "pi.target", "foo" },
.{ "let pi2 = pi.cloneNode()", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "let nimp = document.getElementById('content')", "undefined" },
.{ "var v = document.importNode(nimp)", "undefined" },
.{ "v.nodeName", "DIV" },
}, .{});
try runner.testCases(&.{
.{ "var v = document.createAttribute('foo')", "undefined" },
.{ "v.nodeName", "foo" },
}, .{});
try runner.testCases(&.{
.{ "document.children.length", "1" },
.{ "document.children.item(0).nodeName", "HTML" },
.{ "document.firstElementChild.nodeName", "HTML" },
.{ "document.lastElementChild.nodeName", "HTML" },
.{ "document.childElementCount", "1" },
.{ "let nd = new Document()", "undefined" },
.{ "nd.children.length", "0" },
.{ "nd.children.item(0)", "null" },
.{ "nd.firstElementChild", "null" },
.{ "nd.lastElementChild", "null" },
.{ "nd.childElementCount", "0" },
.{ "let emptydoc = document.createElement('html')", "undefined" },
.{ "emptydoc.prepend(document.createElement('html'))", "undefined" },
.{ "let emptydoc2 = document.createElement('html')", "undefined" },
.{ "emptydoc2.append(document.createElement('html'))", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "document.querySelector('')", "null" },
.{ "document.querySelector('*').nodeName", "HTML" },
.{ "document.querySelector('#content').id", "content" },
.{ "document.querySelector('#para').id", "para" },
.{ "document.querySelector('.ok').id", "link" },
.{ "document.querySelector('a ~ p').id", "para-empty" },
.{ "document.querySelector(':root').nodeName", "HTML" },
.{ "document.querySelectorAll('p').length", "2" },
.{
\\ Array.from(document.querySelectorAll('#content > p#para-empty'))
\\ .map(row => row.querySelector('span').textContent)
\\ .length;
,
"1",
},
}, .{});
// this test breaks the doc structure, keep it at the end of the test
// suite.
try runner.testCases(&.{
.{ "let nadop = document.getElementById('content')", "undefined" },
.{ "var v = document.adoptNode(nadop)", "undefined" },
.{ "v.nodeName", "DIV" },
}, .{});
const Case = testing.JsRunner.Case;
const tags = comptime parser.Tag.all();
var createElements: [(tags.len) * 2]Case = undefined;
inline for (tags, 0..) |tag, i| {
const tag_name = @tagName(tag);
createElements[i * 2] = Case{
"var " ++ tag_name ++ "Elem = document.createElement('" ++ tag_name ++ "')",
"undefined",
};
createElements[(i * 2) + 1] = Case{
tag_name ++ "Elem.localName",
tag_name,
};
}
try runner.testCases(&createElements, .{});
}

View File

@@ -0,0 +1,64 @@
// 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 SessionState = @import("../env.zig").SessionState;
const Node = @import("node.zig").Node;
// WEB IDL https://dom.spec.whatwg.org/#documentfragment
pub const DocumentFragment = struct {
pub const Self = parser.DocumentFragment;
pub const prototype = *Node;
pub const subtype = .node;
pub fn constructor(state: *const SessionState) !*parser.DocumentFragment {
return parser.documentCreateDocumentFragment(
parser.documentHTMLToDocument(state.document.?),
);
}
pub fn _isEqualNode(self: *parser.DocumentFragment, other_node: *parser.Node) !bool {
const other_type = try parser.nodeType(other_node);
if (other_type != .document_fragment) {
return false;
}
_ = self;
return true;
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.DocumentFragment" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "const dc = new DocumentFragment()", "undefined" },
.{ "dc.constructor.name", "DocumentFragment" },
}, .{});
try runner.testCases(&.{
.{ "const dc1 = new DocumentFragment()", "undefined" },
.{ "const dc2 = new DocumentFragment()", "undefined" },
.{ "dc1.isEqualNode(dc1)", "true" },
.{ "dc1.isEqualNode(dc2)", "true" },
}, .{});
}

View File

@@ -0,0 +1,80 @@
// 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 Node = @import("node.zig").Node;
// WEB IDL https://dom.spec.whatwg.org/#documenttype
pub const DocumentType = struct {
pub const Self = parser.DocumentType;
pub const prototype = *Node;
pub const subtype = .node;
pub fn get_name(self: *parser.DocumentType) ![]const u8 {
return try parser.documentTypeGetName(self);
}
pub fn get_publicId(self: *parser.DocumentType) ![]const u8 {
return try parser.documentTypeGetPublicId(self);
}
pub fn get_systemId(self: *parser.DocumentType) ![]const u8 {
return try parser.documentTypeGetSystemId(self);
}
// netsurf's DocumentType doesn't implement the dom_node_get_attributes
// and thus will crash if we try to call nodeIsEqualNode.
pub fn _isEqualNode(self: *parser.DocumentType, other_node: *parser.Node) !bool {
if (try parser.nodeType(other_node) != .document_type) {
return false;
}
const other: *parser.DocumentType = @ptrCast(other_node);
if (std.mem.eql(u8, try get_name(self), try get_name(other)) == false) {
return false;
}
if (std.mem.eql(u8, try get_publicId(self), try get_publicId(other)) == false) {
return false;
}
if (std.mem.eql(u8, try get_systemId(self), try get_systemId(other)) == false) {
return false;
}
return true;
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.DocumentType" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let dt1 = document.implementation.createDocumentType('qname1', 'pid1', 'sys1');", "undefined" },
.{ "let dt2 = document.implementation.createDocumentType('qname2', 'pid2', 'sys2');", "undefined" },
.{ "let dt3 = document.implementation.createDocumentType('qname1', 'pid1', 'sys1');", "undefined" },
.{ "dt1.isEqualNode(dt1)", "true" },
.{ "dt1.isEqualNode(dt3)", "true" },
.{ "dt1.isEqualNode(dt2)", "false" },
.{ "dt2.isEqualNode(dt3)", "false" },
.{ "dt1.isEqualNode(document)", "false" },
.{ "document.isEqualNode(dt1)", "false" },
}, .{});
}

View File

@@ -16,25 +16,25 @@
// 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 generate = @import("../generate.zig");
const DOMException = @import("exceptions.zig").DOMException;
const EventTarget = @import("event_target.zig").EventTarget;
const DOMImplementation = @import("implementation.zig").DOMImplementation;
const NamedNodeMap = @import("namednodemap.zig").NamedNodeMap;
const DOMTokenList = @import("token_list.zig").DOMTokenList;
const NodeList = @import("nodelist.zig").NodeList;
const Nod = @import("node.zig");
const DOMTokenList = @import("token_list.zig");
const NodeList = @import("nodelist.zig");
const Node = @import("node.zig");
const MutationObserver = @import("mutation_observer.zig");
const IntersectionObserver = @import("intersection_observer.zig");
pub const Interfaces = generate.Tuple(.{
pub const Interfaces = .{
DOMException,
EventTarget,
DOMImplementation,
NamedNodeMap,
DOMTokenList,
NodeList,
Nod.Node,
Nod.Interfaces,
DOMTokenList.Interfaces,
NodeList.Interfaces,
Node.Node,
Node.Interfaces,
MutationObserver.Interfaces,
});
IntersectionObserver.Interfaces,
};

View File

@@ -18,15 +18,11 @@
const std = @import("std");
const parser = @import("netsurf");
const jsruntime = @import("jsruntime");
const Case = jsruntime.test_utils.Case;
const checkCases = jsruntime.test_utils.checkCases;
const Variadic = jsruntime.Variadic;
const parser = @import("../netsurf.zig");
const SessionState = @import("../env.zig").SessionState;
const collection = @import("html_collection.zig");
const writeNode = @import("../browser/dump.zig").writeNode;
const dump = @import("../dump.zig");
const css = @import("css.zig");
const Node = @import("node.zig").Node;
@@ -35,16 +31,22 @@ const NodeList = @import("nodelist.zig").NodeList;
const HTMLElem = @import("../html/elements.zig");
pub const Union = @import("../html/elements.zig").Union;
const DOMException = @import("exceptions.zig").DOMException;
// WEB IDL https://dom.spec.whatwg.org/#element
pub const Element = struct {
pub const Self = parser.Element;
pub const prototype = *Node;
pub const mem_guarantied = true;
pub const subtype = .node;
pub const DOMRect = struct {
x: f64,
y: f64,
width: f64,
height: f64,
};
pub fn toInterface(e: *parser.Element) !Union {
return try HTMLElem.toInterface(Union, e);
// SVGElement and MathML are not supported yet.
}
// JS funcs
@@ -95,17 +97,20 @@ pub const Element = struct {
}
pub fn get_attributes(self: *parser.Element) !*parser.NamedNodeMap {
return try parser.nodeGetAttributes(parser.elementToNode(self));
// An element must have non-nil attributes.
return try parser.nodeGetAttributes(parser.elementToNode(self)) orelse unreachable;
}
pub fn get_innerHTML(self: *parser.Element, alloc: std.mem.Allocator) ![]const u8 {
var buf = std.ArrayList(u8).init(alloc);
defer buf.deinit();
pub fn get_innerHTML(self: *parser.Element, state: *SessionState) ![]const u8 {
var buf = std.ArrayList(u8).init(state.arena);
try dump.writeChildren(parser.elementToNode(self), buf.writer());
return buf.items;
}
try writeNode(parser.elementToNode(self), buf.writer());
// TODO express the caller owned the slice.
// https://github.com/lightpanda-io/jsruntime-lib/issues/195
return buf.toOwnedSlice();
pub fn get_outerHTML(self: *parser.Element, state: *SessionState) ![]const u8 {
var buf = std.ArrayList(u8).init(state.arena);
try dump.writeNode(parser.elementToNode(self), buf.writer());
return buf.items;
}
pub fn set_innerHTML(self: *parser.Element, str: []const u8) !void {
@@ -214,11 +219,11 @@ pub const Element = struct {
pub fn _getElementsByTagName(
self: *parser.Element,
alloc: std.mem.Allocator,
tag_name: []const u8,
state: *SessionState,
) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(
alloc,
state.arena,
parser.elementToNode(self),
tag_name,
false,
@@ -227,11 +232,11 @@ pub const Element = struct {
pub fn _getElementsByClassName(
self: *parser.Element,
alloc: std.mem.Allocator,
classNames: []const u8,
state: *SessionState,
) !collection.HTMLCollection {
return try collection.HTMLCollectionByClassName(
alloc,
state.arena,
parser.elementToNode(self),
classNames,
false,
@@ -294,180 +299,234 @@ pub const Element = struct {
}
}
pub fn _querySelector(self: *parser.Element, alloc: std.mem.Allocator, selector: []const u8) !?Union {
pub fn _querySelector(self: *parser.Element, selector: []const u8, state: *SessionState) !?Union {
if (selector.len == 0) return null;
const n = try css.querySelector(alloc, parser.elementToNode(self), selector);
const n = try css.querySelector(state.arena, parser.elementToNode(self), selector);
if (n == null) return null;
return try toInterface(parser.nodeToElement(n.?));
}
pub fn _querySelectorAll(self: *parser.Element, alloc: std.mem.Allocator, selector: []const u8) !NodeList {
return css.querySelectorAll(alloc, parser.elementToNode(self), selector);
pub fn _querySelectorAll(self: *parser.Element, selector: []const u8, state: *SessionState) !NodeList {
return css.querySelectorAll(state.arena, parser.elementToNode(self), selector);
}
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
// function must accept either node or string.
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
pub fn _prepend(self: *parser.Element, nodes: ?Variadic(*parser.Node)) !void {
pub fn _prepend(self: *parser.Element, nodes: []const *parser.Node) !void {
return Node.prepend(parser.elementToNode(self), nodes);
}
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
// function must accept either node or string.
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
pub fn _append(self: *parser.Element, nodes: ?Variadic(*parser.Node)) !void {
pub fn _append(self: *parser.Element, nodes: []const *parser.Node) !void {
return Node.append(parser.elementToNode(self), nodes);
}
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
// function must accept either node or string.
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
pub fn _replaceChildren(self: *parser.Element, nodes: ?Variadic(*parser.Node)) !void {
pub fn _replaceChildren(self: *parser.Element, nodes: []const *parser.Node) !void {
return Node.replaceChildren(parser.elementToNode(self), nodes);
}
pub fn deinit(_: *parser.Element, _: std.mem.Allocator) void {}
pub fn _getBoundingClientRect(self: *parser.Element, state: *SessionState) !DOMRect {
return state.renderer.getRect(self);
}
// returns a collection of DOMRect objects that indicate the bounding rectangles for each CSS border box in a client.
// We do not render so just always return the element's rect.
pub fn _getClientRects(self: *parser.Element, state: *SessionState) ![1]DOMRect {
return [_]DOMRect{try state.renderer.getRect(self)};
}
pub fn get_clientWidth(_: *parser.Element, state: *SessionState) u32 {
return state.renderer.width();
}
pub fn get_clientHeight(_: *parser.Element, state: *SessionState) u32 {
return state.renderer.height();
}
pub fn _matches(self: *parser.Element, selectors: []const u8, state: *SessionState) !bool {
const cssParse = @import("../css/css.zig").parse;
const CssNodeWrap = @import("../css/libdom.zig").Node;
const s = try cssParse(state.call_arena, selectors, .{});
return s.match(CssNodeWrap{ .node = parser.elementToNode(self) });
}
};
// Tests
// -----
pub fn testExecFn(
_: std.mem.Allocator,
js_env: *jsruntime.Env,
) anyerror!void {
var getters = [_]Case{
.{ .src = "let g = document.getElementById('content')", .ex = "undefined" },
.{ .src = "g.namespaceURI", .ex = "http://www.w3.org/1999/xhtml" },
.{ .src = "g.prefix", .ex = "null" },
.{ .src = "g.localName", .ex = "div" },
.{ .src = "g.tagName", .ex = "DIV" },
};
try checkCases(js_env, &getters);
const testing = @import("../../testing.zig");
test "Browser.DOM.Element" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
var gettersetters = [_]Case{
.{ .src = "let gs = document.getElementById('content')", .ex = "undefined" },
.{ .src = "gs.id", .ex = "content" },
.{ .src = "gs.id = 'foo'", .ex = "foo" },
.{ .src = "gs.id", .ex = "foo" },
.{ .src = "gs.id = 'content'", .ex = "content" },
.{ .src = "gs.className", .ex = "" },
.{ .src = "let gs2 = document.getElementById('para-empty')", .ex = "undefined" },
.{ .src = "gs2.className", .ex = "ok empty" },
.{ .src = "gs2.className = 'foo bar baz'", .ex = "foo bar baz" },
.{ .src = "gs2.className", .ex = "foo bar baz" },
.{ .src = "gs2.className = 'ok empty'", .ex = "ok empty" },
.{ .src = "let cl = gs2.classList", .ex = "undefined" },
.{ .src = "cl.length", .ex = "2" },
};
try checkCases(js_env, &gettersetters);
try runner.testCases(&.{
.{ "let g = document.getElementById('content')", "undefined" },
.{ "g.namespaceURI", "http://www.w3.org/1999/xhtml" },
.{ "g.prefix", "null" },
.{ "g.localName", "div" },
.{ "g.tagName", "DIV" },
}, .{});
var attribute = [_]Case{
.{ .src = "let a = document.getElementById('content')", .ex = "undefined" },
.{ .src = "a.hasAttributes()", .ex = "true" },
.{ .src = "a.attributes.length", .ex = "1" },
try runner.testCases(&.{
.{ "let gs = document.getElementById('content')", "undefined" },
.{ "gs.id", "content" },
.{ "gs.id = 'foo'", "foo" },
.{ "gs.id", "foo" },
.{ "gs.id = 'content'", "content" },
.{ "gs.className", "" },
.{ "let gs2 = document.getElementById('para-empty')", "undefined" },
.{ "gs2.className", "ok empty" },
.{ "gs2.className = 'foo bar baz'", "foo bar baz" },
.{ "gs2.className", "foo bar baz" },
.{ "gs2.className = 'ok empty'", "ok empty" },
.{ "let cl = gs2.classList", "undefined" },
.{ "cl.length", "2" },
}, .{});
.{ .src = "a.getAttribute('id')", .ex = "content" },
try runner.testCases(&.{
.{ "let a = document.getElementById('content')", "undefined" },
.{ "a.hasAttributes()", "true" },
.{ "a.attributes.length", "1" },
.{ .src = "a.hasAttribute('foo')", .ex = "false" },
.{ .src = "a.getAttribute('foo')", .ex = "null" },
.{ "a.getAttribute('id')", "content" },
.{ .src = "a.setAttribute('foo', 'bar')", .ex = "undefined" },
.{ .src = "a.hasAttribute('foo')", .ex = "true" },
.{ .src = "a.getAttribute('foo')", .ex = "bar" },
.{ "a.hasAttribute('foo')", "false" },
.{ "a.getAttribute('foo')", "null" },
.{ .src = "a.setAttribute('foo', 'baz')", .ex = "undefined" },
.{ .src = "a.hasAttribute('foo')", .ex = "true" },
.{ .src = "a.getAttribute('foo')", .ex = "baz" },
.{ "a.setAttribute('foo', 'bar')", "undefined" },
.{ "a.hasAttribute('foo')", "true" },
.{ "a.getAttribute('foo')", "bar" },
.{ .src = "a.removeAttribute('foo')", .ex = "undefined" },
.{ .src = "a.hasAttribute('foo')", .ex = "false" },
.{ .src = "a.getAttribute('foo')", .ex = "null" },
};
try checkCases(js_env, &attribute);
.{ "a.setAttribute('foo', 'baz')", "undefined" },
.{ "a.hasAttribute('foo')", "true" },
.{ "a.getAttribute('foo')", "baz" },
var toggleAttr = [_]Case{
.{ .src = "let b = document.getElementById('content')", .ex = "undefined" },
.{ .src = "b.toggleAttribute('foo')", .ex = "true" },
.{ .src = "b.hasAttribute('foo')", .ex = "true" },
.{ .src = "b.getAttribute('foo')", .ex = "" },
.{ "a.removeAttribute('foo')", "undefined" },
.{ "a.hasAttribute('foo')", "false" },
.{ "a.getAttribute('foo')", "null" },
}, .{});
.{ .src = "b.toggleAttribute('foo')", .ex = "false" },
.{ .src = "b.hasAttribute('foo')", .ex = "false" },
};
try checkCases(js_env, &toggleAttr);
try runner.testCases(&.{
.{ "let b = document.getElementById('content')", "undefined" },
.{ "b.toggleAttribute('foo')", "true" },
.{ "b.hasAttribute('foo')", "true" },
.{ "b.getAttribute('foo')", "" },
var parentNode = [_]Case{
.{ .src = "let c = document.getElementById('content')", .ex = "undefined" },
.{ .src = "c.children.length", .ex = "3" },
.{ .src = "c.firstElementChild.nodeName", .ex = "A" },
.{ .src = "c.lastElementChild.nodeName", .ex = "P" },
.{ .src = "c.childElementCount", .ex = "3" },
.{ "b.toggleAttribute('foo')", "false" },
.{ "b.hasAttribute('foo')", "false" },
}, .{});
.{ .src = "c.prepend(document.createTextNode('foo'))", .ex = "undefined" },
.{ .src = "c.append(document.createTextNode('bar'))", .ex = "undefined" },
};
try checkCases(js_env, &parentNode);
try runner.testCases(&.{
.{ "let c = document.getElementById('content')", "undefined" },
.{ "c.children.length", "3" },
.{ "c.firstElementChild.nodeName", "A" },
.{ "c.lastElementChild.nodeName", "P" },
.{ "c.childElementCount", "3" },
var elementSibling = [_]Case{
.{ .src = "let d = document.getElementById('para')", .ex = "undefined" },
.{ .src = "d.previousElementSibling.nodeName", .ex = "P" },
.{ .src = "d.nextElementSibling", .ex = "null" },
};
try checkCases(js_env, &elementSibling);
.{ "c.prepend(document.createTextNode('foo'))", "undefined" },
.{ "c.append(document.createTextNode('bar'))", "undefined" },
}, .{});
var querySelector = [_]Case{
.{ .src = "let e = document.getElementById('content')", .ex = "undefined" },
.{ .src = "e.querySelector('foo')", .ex = "null" },
.{ .src = "e.querySelector('#foo')", .ex = "null" },
.{ .src = "e.querySelector('#link').id", .ex = "link" },
.{ .src = "e.querySelector('#para').id", .ex = "para" },
.{ .src = "e.querySelector('*').id", .ex = "link" },
.{ .src = "e.querySelector('')", .ex = "null" },
.{ .src = "e.querySelector('*').id", .ex = "link" },
.{ .src = "e.querySelector('#content')", .ex = "null" },
.{ .src = "e.querySelector('#para').id", .ex = "para" },
.{ .src = "e.querySelector('.ok').id", .ex = "link" },
.{ .src = "e.querySelector('a ~ p').id", .ex = "para-empty" },
try runner.testCases(&.{
.{ "let d = document.getElementById('para')", "undefined" },
.{ "d.previousElementSibling.nodeName", "P" },
.{ "d.nextElementSibling", "null" },
}, .{});
.{ .src = "e.querySelectorAll('foo').length", .ex = "0" },
.{ .src = "e.querySelectorAll('#foo').length", .ex = "0" },
.{ .src = "e.querySelectorAll('#link').length", .ex = "1" },
.{ .src = "e.querySelectorAll('#link').item(0).id", .ex = "link" },
.{ .src = "e.querySelectorAll('#para').length", .ex = "1" },
.{ .src = "e.querySelectorAll('#para').item(0).id", .ex = "para" },
.{ .src = "e.querySelectorAll('*').length", .ex = "4" },
.{ .src = "e.querySelectorAll('p').length", .ex = "2" },
.{ .src = "e.querySelectorAll('.ok').item(0).id", .ex = "link" },
};
try checkCases(js_env, &querySelector);
try runner.testCases(&.{
.{ "let e = document.getElementById('content')", "undefined" },
.{ "e.querySelector('foo')", "null" },
.{ "e.querySelector('#foo')", "null" },
.{ "e.querySelector('#link').id", "link" },
.{ "e.querySelector('#para').id", "para" },
.{ "e.querySelector('*').id", "link" },
.{ "e.querySelector('')", "null" },
.{ "e.querySelector('*').id", "link" },
.{ "e.querySelector('#content')", "null" },
.{ "e.querySelector('#para').id", "para" },
.{ "e.querySelector('.ok').id", "link" },
.{ "e.querySelector('a ~ p').id", "para-empty" },
var attrNode = [_]Case{
.{ .src = "let f = document.getElementById('content')", .ex = "undefined" },
.{ .src = "let ff = document.createAttribute('foo')", .ex = "undefined" },
.{ .src = "f.setAttributeNode(ff)", .ex = "null" },
.{ .src = "f.getAttributeNode('foo').name", .ex = "foo" },
.{ .src = "f.removeAttributeNode(ff).name", .ex = "foo" },
.{ .src = "f.getAttributeNode('bar')", .ex = "null" },
};
try checkCases(js_env, &attrNode);
.{ "e.querySelectorAll('foo').length", "0" },
.{ "e.querySelectorAll('#foo').length", "0" },
.{ "e.querySelectorAll('#link').length", "1" },
.{ "e.querySelectorAll('#link').item(0).id", "link" },
.{ "e.querySelectorAll('#para').length", "1" },
.{ "e.querySelectorAll('#para').item(0).id", "para" },
.{ "e.querySelectorAll('*').length", "4" },
.{ "e.querySelectorAll('p').length", "2" },
.{ "e.querySelectorAll('.ok').item(0).id", "link" },
}, .{});
var innerHTML = [_]Case{
.{ .src = "document.getElementById('para').innerHTML", .ex = " And" },
.{ .src = "document.getElementById('para-empty').innerHTML.trim()", .ex = "<span id=\"para-empty-child\"></span>" },
try runner.testCases(&.{
.{ "let f = document.getElementById('content')", "undefined" },
.{ "let ff = document.createAttribute('foo')", "undefined" },
.{ "f.setAttributeNode(ff)", "null" },
.{ "f.getAttributeNode('foo').name", "foo" },
.{ "f.removeAttributeNode(ff).name", "foo" },
.{ "f.getAttributeNode('bar')", "null" },
}, .{});
.{ .src = "let h = document.getElementById('para-empty')", .ex = "undefined" },
.{ .src = "const prev = h.innerHTML", .ex = "undefined" },
.{ .src = "h.innerHTML = '<p id=\"hello\">hello world</p>'", .ex = "<p id=\"hello\">hello world</p>" },
.{ .src = "h.innerHTML", .ex = "<p id=\"hello\">hello world</p>" },
.{ .src = "h.firstChild.nodeName", .ex = "P" },
.{ .src = "h.firstChild.id", .ex = "hello" },
.{ .src = "h.firstChild.textContent", .ex = "hello world" },
.{ .src = "h.innerHTML = prev; true", .ex = "true" },
.{ .src = "document.getElementById('para-empty').innerHTML.trim()", .ex = "<span id=\"para-empty-child\"></span>" },
};
try checkCases(js_env, &innerHTML);
try runner.testCases(&.{
.{ "document.getElementById('para').innerHTML", " And" },
.{ "document.getElementById('para-empty').innerHTML.trim()", "<span id=\"para-empty-child\"></span>" },
.{ "let h = document.getElementById('para-empty')", "undefined" },
.{ "const prev = h.innerHTML", "undefined" },
.{ "h.innerHTML = '<p id=\"hello\">hello world</p>'", "<p id=\"hello\">hello world</p>" },
.{ "h.innerHTML", "<p id=\"hello\">hello world</p>" },
.{ "h.firstChild.nodeName", "P" },
.{ "h.firstChild.id", "hello" },
.{ "h.firstChild.textContent", "hello world" },
.{ "h.innerHTML = prev; true", "true" },
.{ "document.getElementById('para-empty').innerHTML.trim()", "<span id=\"para-empty-child\"></span>" },
}, .{});
try runner.testCases(&.{
.{ "document.getElementById('para').outerHTML", "<p id=\"para\"> And</p>" },
}, .{});
try runner.testCases(&.{
.{ "document.getElementById('para').clientWidth", "1" },
.{ "document.getElementById('para').clientHeight", "1" },
.{ "let r1 = document.getElementById('para').getBoundingClientRect()", "undefined" },
.{ "r1.x", "1" },
.{ "r1.y", "0" },
.{ "r1.width", "1" },
.{ "r1.height", "1" },
.{ "let r2 = document.getElementById('content').getBoundingClientRect()", "undefined" },
.{ "r2.x", "2" },
.{ "r2.y", "0" },
.{ "r2.width", "1" },
.{ "r2.height", "1" },
.{ "let r3 = document.getElementById('para').getBoundingClientRect()", "undefined" },
.{ "r3.x", "1" },
.{ "r3.y", "0" },
.{ "r3.width", "1" },
.{ "r3.height", "1" },
.{ "document.getElementById('para').clientWidth", "3" },
.{ "document.getElementById('para').clientHeight", "1" },
}, .{});
try runner.testCases(&.{
.{ "const el = document.createElement('div');", "undefined" },
.{ "el.id = 'matches'; el.className = 'ok';", "ok" },
.{ "el.matches('#matches')", "true" },
.{ "el.matches('.ok')", "true" },
.{ "el.matches('#9000')", "false" },
.{ "el.matches('.notok')", "false" },
}, .{});
}

View File

@@ -0,0 +1,222 @@
// 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");
const SessionState = @import("../env.zig").SessionState;
const EventHandler = @import("../events/event.zig").EventHandler;
const DOMException = @import("exceptions.zig").DOMException;
const Nod = @import("node.zig");
// EventTarget interfaces
pub const Union = Nod.Union;
// EventTarget implementation
pub const EventTarget = struct {
pub const Self = parser.EventTarget;
pub const Exception = DOMException;
pub fn toInterface(et: *parser.EventTarget) !Union {
// NOTE: for now we state that all EventTarget are Nodes
// TODO: handle other types (eg. Window)
return Nod.Node.toInterface(@as(*parser.Node, @ptrCast(et)));
}
// JS funcs
// --------
pub fn _addEventListener(
self: *parser.EventTarget,
typ: []const u8,
cbk: Env.Callback,
capture: ?bool,
state: *SessionState,
// TODO: hanle EventListenerOptions
// see #https://github.com/lightpanda-io/jsruntime-lib/issues/114
) !void {
// check if event target has already this listener
const lst = try parser.eventTargetHasListener(
self,
typ,
capture orelse false,
cbk.id,
);
if (lst != null) {
return;
}
const eh = try EventHandler.init(state.arena, try cbk.withThis(self));
try parser.eventTargetAddEventListener(
self,
typ,
&eh.node,
capture orelse false,
);
}
pub fn _removeEventListener(
self: *parser.EventTarget,
typ: []const u8,
cbk: Env.Callback,
capture: ?bool,
// TODO: hanle EventListenerOptions
// see #https://github.com/lightpanda-io/jsruntime-lib/issues/114
) !void {
// check if event target has already this listener
const lst = try parser.eventTargetHasListener(
self,
typ,
capture orelse false,
cbk.id,
);
if (lst == null) {
return;
}
// remove listener
try parser.eventTargetRemoveEventListener(
self,
typ,
lst.?,
capture orelse false,
);
}
pub fn _dispatchEvent(self: *parser.EventTarget, event: *parser.Event) !bool {
return try parser.eventTargetDispatchEvent(self, event);
}
pub fn deinit(self: *parser.EventTarget, state: *SessionState) void {
parser.eventTargetRemoveAllEventListeners(self, state.arena) catch unreachable;
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.EventTarget" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let content = document.getElementById('content')", "undefined" },
.{ "let para = document.getElementById('para')", "undefined" },
// NOTE: as some event properties will change during the event dispatching phases
// we need to copy thoses values in order to check them afterwards
.{
\\ var nb = 0; var evt; var phase; var cur;
\\ function cbk(event) {
\\ evt = event;
\\ phase = event.eventPhase;
\\ cur = event.currentTarget;
\\ nb ++;
\\ }
,
"undefined",
},
}, .{});
try runner.testCases(&.{
.{ "content.addEventListener('basic', cbk)", "undefined" },
.{ "content.dispatchEvent(new Event('basic'))", "true" },
.{ "nb", "1" },
.{ "evt instanceof Event", "true" },
.{ "evt.type", "basic" },
.{ "phase", "2" },
.{ "cur.getAttribute('id')", "content" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
.{ "para.dispatchEvent(new Event('basic'))", "true" },
.{ "nb", "0" }, // handler is not called, no capture, not the target, no bubbling
.{ "evt === undefined", "true" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{ "content.addEventListener('basic', cbk)", "undefined" },
.{ "content.dispatchEvent(new Event('basic'))", "true" },
.{ "nb", "1" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{ "content.addEventListener('basic', cbk, true)", "undefined" },
.{ "content.dispatchEvent(new Event('basic'))", "true" },
.{ "nb", "2" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{ "content.removeEventListener('basic', cbk)", "undefined" },
.{ "content.dispatchEvent(new Event('basic'))", "true" },
.{ "nb", "1" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{ "content.removeEventListener('basic', cbk, true)", "undefined" },
.{ "content.dispatchEvent(new Event('basic'))", "true" },
.{ "nb", "0" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
.{ "content.addEventListener('capture', cbk, true)", "undefined" },
.{ "content.dispatchEvent(new Event('capture'))", "true" },
.{ "nb", "1" },
.{ "evt instanceof Event", "true" },
.{ "evt.type", "capture" },
.{ "phase", "2" },
.{ "cur.getAttribute('id')", "content" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
.{ "para.dispatchEvent(new Event('capture'))", "true" },
.{ "nb", "1" },
.{ "evt instanceof Event", "true" },
.{ "evt.type", "capture" },
.{ "phase", "1" },
.{ "cur.getAttribute('id')", "content" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
.{ "content.addEventListener('bubbles', cbk)", "undefined" },
.{ "content.dispatchEvent(new Event('bubbles', {bubbles: true}))", "true" },
.{ "nb", "1" },
.{ "evt instanceof Event", "true" },
.{ "evt.type", "bubbles" },
.{ "evt.bubbles", "true" },
.{ "phase", "2" },
.{ "cur.getAttribute('id')", "content" },
}, .{});
try runner.testCases(&.{
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
.{ "para.dispatchEvent(new Event('bubbles', {bubbles: true}))", "true" },
.{ "nb", "1" },
.{ "evt instanceof Event", "true" },
.{ "evt.type", "bubbles" },
.{ "phase", "3" },
.{ "cur.getAttribute('id')", "content" },
}, .{});
}

View File

@@ -19,19 +19,13 @@
const std = @import("std");
const allocPrint = std.fmt.allocPrint;
const jsruntime = @import("jsruntime");
const Case = jsruntime.test_utils.Case;
const checkCases = jsruntime.test_utils.checkCases;
const parser = @import("netsurf");
const parser = @import("../netsurf.zig");
// https://webidl.spec.whatwg.org/#idl-DOMException
pub const DOMException = struct {
err: parser.DOMError,
str: []const u8,
pub const mem_guarantied = true;
pub const ErrorSet = parser.DOMError;
// static attributes
@@ -62,7 +56,7 @@ pub const DOMException = struct {
pub const _DATA_CLONE_ERR = 25;
// TODO: deinit
pub fn init(alloc: std.mem.Allocator, err: anyerror, callerName: []const u8) anyerror!DOMException {
pub fn init(alloc: std.mem.Allocator, err: anyerror, callerName: []const u8) !DOMException {
const errCast = @as(parser.DOMError, @errorCast(err));
const errName = DOMException.name(errCast);
const str = switch (errCast) {
@@ -120,7 +114,7 @@ pub const DOMException = struct {
// JS properties and methods
pub fn get_code(self: DOMException) u8 {
pub fn get_code(self: *const DOMException) u8 {
return switch (self.err) {
error.IndexSize => 1,
error.StringSize => 2,
@@ -157,38 +151,41 @@ pub const DOMException = struct {
};
}
pub fn get_name(self: DOMException) []const u8 {
pub fn get_name(self: *const DOMException) []const u8 {
return DOMException.name(self.err);
}
pub fn get_message(self: DOMException) []const u8 {
pub fn get_message(self: *const DOMException) []const u8 {
const errName = DOMException.name(self.err);
return self.str[errName.len + 2 ..];
}
pub fn _toString(self: DOMException) []const u8 {
pub fn _toString(self: *const DOMException) []const u8 {
return self.str;
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.DOM.Exception" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
pub fn testExecFn(
_: std.mem.Allocator,
js_env: *jsruntime.Env,
) anyerror!void {
const err = "Failed to execute 'appendChild' on 'Node': The new child element contains the parent.";
var cases = [_]Case{
.{ .src = "let content = document.getElementById('content')", .ex = "undefined" },
.{ .src = "let link = document.getElementById('link')", .ex = "undefined" },
try runner.testCases(&.{
.{ "let content = document.getElementById('content')", "undefined" },
.{ "let link = document.getElementById('link')", "undefined" },
// HierarchyRequestError
.{ .src = "var HierarchyRequestError; try {link.appendChild(content)} catch (error) {HierarchyRequestError = error} HierarchyRequestError.name", .ex = "HierarchyRequestError" },
.{ .src = "HierarchyRequestError.code", .ex = "3" },
.{ .src = "HierarchyRequestError.message", .ex = err },
.{ .src = "HierarchyRequestError.toString()", .ex = "HierarchyRequestError: " ++ err },
.{ .src = "HierarchyRequestError instanceof DOMException", .ex = "true" },
.{ .src = "HierarchyRequestError instanceof Error", .ex = "true" },
};
try checkCases(js_env, &cases);
.{
\\ var he;
\\ try { link.appendChild(content) } catch (error) { he = error}
\\ he.name
,
"HierarchyRequestError",
},
.{ "he.code", "3" },
.{ "he.message", err },
.{ "he.toString()", "HierarchyRequestError: " ++ err },
.{ "he instanceof DOMException", "true" },
.{ "he instanceof Error", "true" },
}, .{});
}

View File

@@ -17,22 +17,14 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
const parser = @import("netsurf");
const parser = @import("../netsurf.zig");
const jsruntime = @import("jsruntime");
const Case = jsruntime.test_utils.Case;
const checkCases = jsruntime.test_utils.checkCases;
const generate = @import("../generate.zig");
const utils = @import("utils.z");
const Element = @import("element.zig").Element;
const Union = @import("element.zig").Union;
const JsThis = @import("../env.zig").JsThis;
const Walker = @import("walker.zig").Walker;
const WalkerDepthFirst = @import("walker.zig").WalkerDepthFirst;
const WalkerChildren = @import("walker.zig").WalkerChildren;
const WalkerNone = @import("walker.zig").WalkerNone;
const Matcher = union(enum) {
matchByName: MatchByName,
@@ -45,25 +37,11 @@ const Matcher = union(enum) {
pub fn match(self: Matcher, node: *parser.Node) !bool {
switch (self) {
inline .matchTrue => return true,
inline .matchFalse => return false,
inline .matchByTagName => |case| return case.match(node),
inline .matchByClassName => |case| return case.match(node),
inline .matchByName => |case| return case.match(node),
inline .matchByLinks => return MatchByLinks.match(node),
inline .matchByAnchors => return MatchByAnchors.match(node),
}
}
pub fn deinit(self: Matcher, alloc: std.mem.Allocator) void {
switch (self) {
inline .matchTrue => return,
inline .matchFalse => return,
inline .matchByTagName => |case| return case.deinit(alloc),
inline .matchByClassName => |case| return case.deinit(alloc),
inline .matchByName => |case| return case.deinit(alloc),
inline .matchByLinks => return,
inline .matchByAnchors => return,
.matchTrue => return true,
.matchFalse => return false,
.matchByLinks => return MatchByLinks.match(node),
.matchByAnchors => return MatchByAnchors.match(node),
inline else => |m| return m.match(node),
}
}
};
@@ -74,54 +52,49 @@ pub const MatchByTagName = struct {
tag: []const u8,
is_wildcard: bool,
fn init(alloc: std.mem.Allocator, tag_name: []const u8) !MatchByTagName {
const tag_name_alloc = try alloc.alloc(u8, tag_name.len);
@memcpy(tag_name_alloc, tag_name);
return MatchByTagName{
.tag = tag_name_alloc,
.is_wildcard = std.mem.eql(u8, tag_name, "*"),
fn init(arena: Allocator, tag_name: []const u8) !MatchByTagName {
if (std.mem.eql(u8, tag_name, "*")) {
return .{ .tag = "*", .is_wildcard = true };
}
return .{
.tag = try arena.dupe(u8, tag_name),
.is_wildcard = false,
};
}
pub fn match(self: MatchByTagName, node: *parser.Node) !bool {
return self.is_wildcard or std.ascii.eqlIgnoreCase(self.tag, try parser.nodeName(node));
}
fn deinit(self: MatchByTagName, alloc: std.mem.Allocator) void {
alloc.free(self.tag);
}
};
pub fn HTMLCollectionByTagName(
alloc: std.mem.Allocator,
arena: Allocator,
root: ?*parser.Node,
tag_name: []const u8,
include_root: bool,
) !HTMLCollection {
return HTMLCollection{
.root = root,
.walker = Walker{ .walkerDepthFirst = .{} },
.matcher = Matcher{
.matchByTagName = try MatchByTagName.init(alloc, tag_name),
},
.walker = .{ .walkerDepthFirst = .{} },
.matcher = .{ .matchByTagName = try MatchByTagName.init(arena, tag_name) },
.include_root = include_root,
};
}
pub const MatchByClassName = struct {
classNames: []const u8,
class_names: []const u8,
fn init(alloc: std.mem.Allocator, classNames: []const u8) !MatchByClassName {
const class_names_alloc = try alloc.alloc(u8, classNames.len);
@memcpy(class_names_alloc, classNames);
return MatchByClassName{
.classNames = class_names_alloc,
fn init(arena: Allocator, class_names: []const u8) !MatchByClassName {
return .{
.class_names = try arena.dupe(u8, class_names),
};
}
pub fn match(self: MatchByClassName, node: *parser.Node) !bool {
var it = std.mem.splitAny(u8, self.classNames, " ");
const e = parser.nodeToElement(node);
var it = std.mem.splitScalar(u8, self.class_names, ' ');
while (it.next()) |c| {
if (!try parser.elementHasClass(e, c)) {
return false;
@@ -130,24 +103,18 @@ pub const MatchByClassName = struct {
return true;
}
fn deinit(self: MatchByClassName, alloc: std.mem.Allocator) void {
alloc.free(self.classNames);
}
};
pub fn HTMLCollectionByClassName(
alloc: std.mem.Allocator,
arena: Allocator,
root: ?*parser.Node,
classNames: []const u8,
include_root: bool,
) !HTMLCollection {
return HTMLCollection{
.root = root,
.walker = Walker{ .walkerDepthFirst = .{} },
.matcher = Matcher{
.matchByClassName = try MatchByClassName.init(alloc, classNames),
},
.walker = .{ .walkerDepthFirst = .{} },
.matcher = .{ .matchByClassName = try MatchByClassName.init(arena, classNames) },
.include_root = include_root,
};
}
@@ -155,11 +122,9 @@ pub fn HTMLCollectionByClassName(
pub const MatchByName = struct {
name: []const u8,
fn init(alloc: std.mem.Allocator, name: []const u8) !MatchByName {
const names_alloc = try alloc.alloc(u8, name.len);
@memcpy(names_alloc, name);
return MatchByName{
.name = names_alloc,
fn init(arena: Allocator, name: []const u8) !MatchByName {
return .{
.name = try arena.dupe(u8, name),
};
}
@@ -168,24 +133,18 @@ pub const MatchByName = struct {
const nname = try parser.elementGetAttribute(e, "name") orelse return false;
return std.mem.eql(u8, self.name, nname);
}
fn deinit(self: MatchByName, alloc: std.mem.Allocator) void {
alloc.free(self.name);
}
};
pub fn HTMLCollectionByName(
alloc: std.mem.Allocator,
arena: Allocator,
root: ?*parser.Node,
name: []const u8,
include_root: bool,
) !HTMLCollection {
return HTMLCollection{
.root = root,
.walker = Walker{ .walkerDepthFirst = .{} },
.matcher = Matcher{
.matchByName = try MatchByName.init(alloc, name),
},
.walker = .{ .walkerDepthFirst = .{} },
.matcher = .{ .matchByName = try MatchByName.init(arena, name) },
.include_root = include_root,
};
}
@@ -196,8 +155,8 @@ pub fn HTMLCollectionAll(
) !HTMLCollection {
return HTMLCollection{
.root = root,
.walker = Walker{ .walkerDepthFirst = .{} },
.matcher = Matcher{ .matchTrue = .{} },
.walker = .{ .walkerDepthFirst = .{} },
.matcher = .{ .matchTrue = .{} },
.include_root = include_root,
};
}
@@ -208,8 +167,8 @@ pub fn HTMLCollectionChildren(
) !HTMLCollection {
return HTMLCollection{
.root = root,
.walker = Walker{ .walkerChildren = .{} },
.matcher = Matcher{ .matchTrue = .{} },
.walker = .{ .walkerChildren = .{} },
.matcher = .{ .matchTrue = .{} },
.include_root = include_root,
};
}
@@ -217,8 +176,8 @@ pub fn HTMLCollectionChildren(
pub fn HTMLCollectionEmpty() !HTMLCollection {
return HTMLCollection{
.root = null,
.walker = Walker{ .walkerNone = .{} },
.matcher = Matcher{ .matchFalse = .{} },
.walker = .{ .walkerNone = .{} },
.matcher = .{ .matchFalse = .{} },
.include_root = false,
};
}
@@ -243,10 +202,8 @@ pub fn HTMLCollectionByLinks(
) !HTMLCollection {
return HTMLCollection{
.root = root,
.walker = Walker{ .walkerDepthFirst = .{} },
.matcher = Matcher{
.matchByLinks = MatchByLinks{},
},
.walker = .{ .walkerDepthFirst = .{} },
.matcher = .{ .matchByLinks = MatchByLinks{} },
.include_root = include_root,
};
}
@@ -270,17 +227,13 @@ pub fn HTMLCollectionByAnchors(
) !HTMLCollection {
return HTMLCollection{
.root = root,
.walker = Walker{ .walkerDepthFirst = .{} },
.matcher = Matcher{
.matchByAnchors = MatchByAnchors{},
},
.walker = .{ .walkerDepthFirst = .{} },
.matcher = .{ .matchByAnchors = MatchByAnchors{} },
.include_root = include_root,
};
}
pub const HTMLCollectionIterator = struct {
pub const mem_guarantied = true;
coll: *HTMLCollection,
index: u32 = 0,
@@ -311,8 +264,6 @@ pub const HTMLCollectionIterator = struct {
// dom_html_collection expects a comparison function callback as arguement.
// But we wanted a dynamically comparison here, according to the match tagname.
pub const HTMLCollection = struct {
pub const mem_guarantied = true;
matcher: Matcher,
walker: Walker,
@@ -324,15 +275,11 @@ pub const HTMLCollection = struct {
include_root: bool = false,
// save a state for the collection to improve the _item speed.
cur_idx: ?u32 = undefined,
cur_node: ?*parser.Node = undefined,
// array_like_keys is used to keep reference to array like interface implementation.
// the collection generates keys string which must be free on deinit.
array_like_keys: std.ArrayListUnmanaged([]u8) = .{},
cur_idx: ?u32 = null,
cur_node: ?*parser.Node = null,
// start returns the first node to walk on.
fn start(self: HTMLCollection) !?*parser.Node {
fn start(self: *const HTMLCollection) !?*parser.Node {
if (self.root == null) return null;
if (self.include_root) {
@@ -412,7 +359,7 @@ pub const HTMLCollection = struct {
return try Element.toInterface(e);
}
pub fn _namedItem(self: *HTMLCollection, name: []const u8) !?Union {
pub fn _namedItem(self: *const HTMLCollection, name: []const u8) !?Union {
if (self.root == null) return null;
if (name.len == 0) return null;
@@ -454,81 +401,67 @@ pub const HTMLCollection = struct {
return null;
}
pub fn postAttach(self: *HTMLCollection, alloc: std.mem.Allocator, js_obj: jsruntime.JSObject) !void {
const ln = try self.get_length();
var i: u32 = 0;
while (i < ln) {
defer i += 1;
const k = try std.fmt.allocPrint(alloc, "{d}", .{i});
try self.array_like_keys.append(alloc, k);
const node = try self.item(i) orelse unreachable;
pub fn postAttach(self: *HTMLCollection, js_this: JsThis) !void {
const len = try self.get_length();
for (0..len) |i| {
const node = try self.item(@intCast(i)) orelse unreachable;
const e = @as(*parser.Element, @ptrCast(node));
try js_obj.set(k, e);
try js_this.setIndex(@intCast(i), e);
if (try item_name(e)) |name| {
try js_obj.set(name, e);
try js_this.set(name, e);
}
}
}
pub fn deinit(self: *HTMLCollection, alloc: std.mem.Allocator) void {
for (self.array_like_keys_) |k| alloc.free(k);
self.array_like_keys.deinit(alloc);
self.matcher.deinit(alloc);
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.DOM.HTMLCollection" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
pub fn testExecFn(
_: std.mem.Allocator,
js_env: *jsruntime.Env,
) anyerror!void {
var getElementsByTagName = [_]Case{
.{ .src = "let getElementsByTagName = document.getElementsByTagName('p')", .ex = "undefined" },
.{ .src = "getElementsByTagName.length", .ex = "2" },
.{ .src = "let getElementsByTagNameCI = document.getElementsByTagName('P')", .ex = "undefined" },
.{ .src = "getElementsByTagNameCI.length", .ex = "2" },
.{ .src = "getElementsByTagName.item(0).localName", .ex = "p" },
.{ .src = "getElementsByTagName.item(1).localName", .ex = "p" },
.{ .src = "let getElementsByTagNameAll = document.getElementsByTagName('*')", .ex = "undefined" },
.{ .src = "getElementsByTagNameAll.length", .ex = "8" },
.{ .src = "getElementsByTagNameAll.item(0).localName", .ex = "html" },
.{ .src = "getElementsByTagNameAll.item(0).localName", .ex = "html" },
.{ .src = "getElementsByTagNameAll.item(1).localName", .ex = "head" },
.{ .src = "getElementsByTagNameAll.item(0).localName", .ex = "html" },
.{ .src = "getElementsByTagNameAll.item(2).localName", .ex = "body" },
.{ .src = "getElementsByTagNameAll.item(3).localName", .ex = "div" },
.{ .src = "getElementsByTagNameAll.item(7).localName", .ex = "p" },
.{ .src = "getElementsByTagNameAll.namedItem('para-empty-child').localName", .ex = "span" },
try runner.testCases(&.{
.{ "let getElementsByTagName = document.getElementsByTagName('p')", "undefined" },
.{ "getElementsByTagName.length", "2" },
.{ "let getElementsByTagNameCI = document.getElementsByTagName('P')", "undefined" },
.{ "getElementsByTagNameCI.length", "2" },
.{ "getElementsByTagName.item(0).localName", "p" },
.{ "getElementsByTagName.item(1).localName", "p" },
.{ "let getElementsByTagNameAll = document.getElementsByTagName('*')", "undefined" },
.{ "getElementsByTagNameAll.length", "8" },
.{ "getElementsByTagNameAll.item(0).localName", "html" },
.{ "getElementsByTagNameAll.item(0).localName", "html" },
.{ "getElementsByTagNameAll.item(1).localName", "head" },
.{ "getElementsByTagNameAll.item(0).localName", "html" },
.{ "getElementsByTagNameAll.item(2).localName", "body" },
.{ "getElementsByTagNameAll.item(3).localName", "div" },
.{ "getElementsByTagNameAll.item(7).localName", "p" },
.{ "getElementsByTagNameAll.namedItem('para-empty-child').localName", "span" },
// array like
.{ .src = "getElementsByTagNameAll[0].localName", .ex = "html" },
.{ .src = "getElementsByTagNameAll[7].localName", .ex = "p" },
.{ .src = "getElementsByTagNameAll[8]", .ex = "undefined" },
.{ .src = "getElementsByTagNameAll['para-empty-child'].localName", .ex = "span" },
.{ .src = "getElementsByTagNameAll['foo']", .ex = "undefined" },
.{ "getElementsByTagNameAll[0].localName", "html" },
.{ "getElementsByTagNameAll[7].localName", "p" },
.{ "getElementsByTagNameAll[8]", "undefined" },
.{ "getElementsByTagNameAll['para-empty-child'].localName", "span" },
.{ "getElementsByTagNameAll['foo']", "undefined" },
.{ .src = "document.getElementById('content').getElementsByTagName('*').length", .ex = "4" },
.{ .src = "document.getElementById('content').getElementsByTagName('p').length", .ex = "2" },
.{ .src = "document.getElementById('content').getElementsByTagName('div').length", .ex = "0" },
.{ "document.getElementById('content').getElementsByTagName('*').length", "4" },
.{ "document.getElementById('content').getElementsByTagName('p').length", "2" },
.{ "document.getElementById('content').getElementsByTagName('div').length", "0" },
.{ .src = "document.children.length", .ex = "1" },
.{ .src = "document.getElementById('content').children.length", .ex = "3" },
.{ "document.children.length", "1" },
.{ "document.getElementById('content').children.length", "3" },
// check liveness
.{ .src = "let content = document.getElementById('content')", .ex = "undefined" },
.{ .src = "let pe = document.getElementById('para-empty')", .ex = "undefined" },
.{ .src = "let p = document.createElement('p')", .ex = "undefined" },
.{ .src = "p.textContent = 'OK live'", .ex = "OK live" },
.{ .src = "getElementsByTagName.item(1).textContent", .ex = " And" },
.{ .src = "content.appendChild(p) != undefined", .ex = "true" },
.{ .src = "getElementsByTagName.length", .ex = "3" },
.{ .src = "getElementsByTagName.item(2).textContent", .ex = "OK live" },
.{ .src = "content.insertBefore(p, pe) != undefined", .ex = "true" },
.{ .src = "getElementsByTagName.item(0).textContent", .ex = "OK live" },
};
try checkCases(js_env, &getElementsByTagName);
.{ "let content = document.getElementById('content')", "undefined" },
.{ "let pe = document.getElementById('para-empty')", "undefined" },
.{ "let p = document.createElement('p')", "undefined" },
.{ "p.textContent = 'OK live'", "OK live" },
.{ "getElementsByTagName.item(1).textContent", " And" },
.{ "content.appendChild(p) != undefined", "true" },
.{ "getElementsByTagName.length", "3" },
.{ "getElementsByTagName.item(2).textContent", "OK live" },
.{ "content.insertBefore(p, pe) != undefined", "true" },
.{ "getElementsByTagName.item(0).textContent", "OK live" },
}, .{});
}

View File

@@ -0,0 +1,69 @@
// 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");
const DOMException = @import("exceptions.zig").DOMException;
// WEB IDL https://dom.spec.whatwg.org/#domimplementation
pub const DOMImplementation = struct {
pub const Exception = DOMException;
pub fn _createDocumentType(
_: *DOMImplementation,
qname: [:0]const u8,
publicId: [:0]const u8,
systemId: [:0]const u8,
) !*parser.DocumentType {
return try parser.domImplementationCreateDocumentType(qname, publicId, systemId);
}
pub fn _createDocument(
_: *DOMImplementation,
namespace: ?[:0]const u8,
qname: ?[:0]const u8,
doctype: ?*parser.DocumentType,
) !*parser.Document {
return try parser.domImplementationCreateDocument(namespace, qname, doctype);
}
pub fn _createHTMLDocument(_: *DOMImplementation, title: ?[]const u8) !*parser.DocumentHTML {
return try parser.domImplementationCreateHTMLDocument(title);
}
pub fn _hasFeature(_: *DOMImplementation) bool {
return true;
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.DOM.Implementation" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let impl = document.implementation", "undefined" },
.{ "impl.createHTMLDocument();", "[object HTMLDocument]" },
.{ "impl.createHTMLDocument('foo');", "[object HTMLDocument]" },
.{ "impl.createDocument(null, 'foo');", "[object Document]" },
.{ "impl.createDocumentType('foo', 'bar', 'baz')", "[object DocumentType]" },
.{ "impl.hasFeature()", "true" },
}, .{});
}

View File

@@ -0,0 +1,278 @@
// 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 parser = @import("../netsurf.zig");
const SessionState = @import("../env.zig").SessionState;
const Env = @import("../env.zig").Env;
const Element = @import("element.zig").Element;
const Document = @import("document.zig").Document;
pub const Interfaces = .{
IntersectionObserver,
IntersectionObserverEntry,
};
const log = std.log.scoped(.events);
// This is supposed to listen to change between the root and observation targets.
// However, our rendered stores everything as 1 pixel sized boxes in a long row that never changes.
// As such, there are no changes to intersections between the root and any target.
// Instead we keep a list of all entries that are being observed.
// The callback is called with all entries everytime a new entry is added(observed).
// Potentially we should also call the callback at a regular interval.
// The returned Entries are phony, they always indicate full intersection.
// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver
pub const IntersectionObserver = struct {
callback: Env.Callback,
options: IntersectionObserverOptions,
state: *SessionState,
observed_entries: std.ArrayListUnmanaged(IntersectionObserverEntry),
// new IntersectionObserver(callback)
// new IntersectionObserver(callback, options) [not supported yet]
pub fn constructor(callback: Env.Callback, options_: ?IntersectionObserverOptions, state: *SessionState) !IntersectionObserver {
var options = IntersectionObserverOptions{
.root = parser.documentToNode(parser.documentHTMLToDocument(state.document.?)),
.rootMargin = "0px 0px 0px 0px",
.threshold = &.{0.0},
};
if (options_) |*o| {
if (o.root) |root| {
options.root = root;
} // Other properties are not used due to the way we render
}
return .{
.callback = callback,
.options = options,
.state = state,
.observed_entries = .{},
};
}
pub fn _disconnect(self: *IntersectionObserver) !void {
self.observed_entries = .{}; // We don't free as it is on an arena
}
pub fn _observe(self: *IntersectionObserver, target_element: *parser.Element) !void {
for (self.observed_entries.items) |*observer| {
if (observer.target == target_element) {
return; // Already observed
}
}
try self.observed_entries.append(self.state.arena, .{
.state = self.state,
.target = target_element,
.options = &self.options,
});
var result: Env.Callback.Result = undefined;
self.callback.tryCall(.{self.observed_entries.items}, &result) catch {
log.err("intersection observer callback error: {s}", .{result.exception});
log.debug("stack:\n{s}", .{result.stack orelse "???"});
};
}
pub fn _unobserve(self: *IntersectionObserver, target: *parser.Element) !void {
for (self.observed_entries.items, 0..) |*observer, index| {
if (observer.target == target) {
_ = self.observed_entries.swapRemove(index);
break;
}
}
}
pub fn _takeRecords(self: *IntersectionObserver) []IntersectionObserverEntry {
return self.observed_entries.items;
}
};
const IntersectionObserverOptions = struct {
root: ?*parser.Node, // Element or Document
rootMargin: ?[]const u8,
threshold: ?[]const f32,
};
// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry
// https://w3c.github.io/IntersectionObserver/#intersection-observer-entry
pub const IntersectionObserverEntry = struct {
state: *SessionState,
target: *parser.Element,
options: *IntersectionObserverOptions,
// Returns the bounds rectangle of the target element as a DOMRectReadOnly. The bounds are computed as described in the documentation for Element.getBoundingClientRect().
pub fn get_boundingClientRect(self: *const IntersectionObserverEntry) !Element.DOMRect {
return self.state.renderer.getRect(self.target);
}
// Returns the ratio of the intersectionRect to the boundingClientRect.
pub fn get_intersectionRatio(_: *const IntersectionObserverEntry) f32 {
return 1.0;
}
// Returns a DOMRectReadOnly representing the target's visible area.
pub fn get_intersectionRect(self: *const IntersectionObserverEntry) !Element.DOMRect {
return self.state.renderer.getRect(self.target);
}
// A Boolean value which is true if the target element intersects with the intersection observer's root. If this is true, then, the IntersectionObserverEntry describes a transition into a state of intersection; if it's false, then you know the transition is from intersecting to not-intersecting.
pub fn get_isIntersecting(_: *const IntersectionObserverEntry) bool {
return true;
}
// Returns a DOMRectReadOnly for the intersection observer's root.
pub fn get_rootBounds(self: *const IntersectionObserverEntry) !Element.DOMRect {
const root = self.options.root.?;
if (@intFromPtr(root) == @intFromPtr(self.state.document.?)) {
return self.state.renderer.boundingRect();
}
const root_type = try parser.nodeType(root);
var element: *parser.Element = undefined;
switch (root_type) {
.element => element = parser.nodeToElement(root),
.document => {
const doc = parser.nodeToDocument(root);
element = (try parser.documentGetDocumentElement(doc)).?;
},
else => return error.InvalidState,
}
return try self.state.renderer.getRect(element);
}
// The Element whose intersection with the root changed.
pub fn get_target(self: *const IntersectionObserverEntry) *parser.Element {
return self.target;
}
// TODO: pub fn get_time(self: *const IntersectionObserverEntry)
};
const testing = @import("../../testing.zig");
test "Browser.DOM.IntersectionObserver" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "new IntersectionObserver(() => {}).observe(document.documentElement);", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "let count_a = 0;", "undefined" },
.{ "const a1 = document.createElement('div');", "undefined" },
.{ "new IntersectionObserver(entries => {count_a += 1;}).observe(a1);", "undefined" },
.{ "count_a;", "1" },
}, .{});
// This test is documenting current behavior, not correct behavior.
// Currently every time observe is called, the callback is called with all entries.
try runner.testCases(&.{
.{ "let count_b = 0;", "undefined" },
.{ "let observer_b = new IntersectionObserver(entries => {count_b = entries.length;});", "undefined" },
.{ "const b1 = document.createElement('div');", "undefined" },
.{ "observer_b.observe(b1);", "undefined" },
.{ "count_b;", "1" },
.{ "const b2 = document.createElement('div');", "undefined" },
.{ "observer_b.observe(b2);", "undefined" },
.{ "count_b;", "2" },
}, .{});
// Re-observing is a no-op
try runner.testCases(&.{
.{ "let count_bb = 0;", "undefined" },
.{ "let observer_bb = new IntersectionObserver(entries => {count_bb = entries.length;});", "undefined" },
.{ "const bb1 = document.createElement('div');", "undefined" },
.{ "observer_bb.observe(bb1);", "undefined" },
.{ "count_bb;", "1" },
.{ "observer_bb.observe(bb1);", "undefined" },
.{ "count_bb;", "1" }, // Still 1, not 2
}, .{});
// Unobserve
try runner.testCases(&.{
.{ "let count_c = 0;", "undefined" },
.{ "let observer_c = new IntersectionObserver(entries => { count_c = entries.length;});", "undefined" },
.{ "const c1 = document.createElement('div');", "undefined" },
.{ "observer_c.observe(c1);", "undefined" },
.{ "count_c;", "1" },
.{ "observer_c.unobserve(c1);", "undefined" },
.{ "const c2 = document.createElement('div');", "undefined" },
.{ "observer_c.observe(c2);", "undefined" },
.{ "count_c;", "1" },
}, .{});
// Disconnect
try runner.testCases(&.{
.{ "let observer_d = new IntersectionObserver(entries => {});", "undefined" },
.{ "let d1 = document.createElement('div');", "undefined" },
.{ "observer_d.observe(d1);", "undefined" },
.{ "observer_d.disconnect();", "undefined" },
.{ "observer_d.takeRecords().length;", "0" },
}, .{});
// takeRecords
try runner.testCases(&.{
.{ "let observer_e = new IntersectionObserver(entries => {});", "undefined" },
.{ "let e1 = document.createElement('div');", "undefined" },
.{ "observer_e.observe(e1);", "undefined" },
.{ "const e2 = document.createElement('div');", "undefined" },
.{ "observer_e.observe(e2);", "undefined" },
.{ "observer_e.takeRecords().length;", "2" },
}, .{});
// Entry
try runner.testCases(&.{
.{ "let entry;", "undefined" },
.{ "new IntersectionObserver(entries => { entry = entries[0]; }).observe(document.createElement('div'));", "undefined" },
.{ "entry.boundingClientRect.x;", "1" },
.{ "entry.intersectionRatio;", "1" },
.{ "entry.intersectionRect.x;", "1" },
.{ "entry.intersectionRect.y;", "0" },
.{ "entry.intersectionRect.width;", "1" },
.{ "entry.intersectionRect.height;", "1" },
.{ "entry.isIntersecting;", "true" },
.{ "entry.rootBounds.x;", "0" },
.{ "entry.rootBounds.y;", "0" },
.{ "entry.rootBounds.width;", "2" },
.{ "entry.rootBounds.height;", "1" },
.{ "entry.target;", "[object HTMLDivElement]" },
}, .{});
// Options
try runner.testCases(&.{
.{ "const new_root = document.createElement('span');", "undefined" },
.{ "let new_entry;", "undefined" },
.{
\\ const new_observer = new IntersectionObserver(
\\ entries => { new_entry = entries[0]; },
\\ {root: new_root, rootMargin: '0px 0px 0px 0px', threshold: [0]});
,
"undefined",
},
.{ "new_observer.observe(document.createElement('div'));", "undefined" },
.{ "new_entry.rootBounds.x;", "2" },
}, .{});
}

View File

@@ -0,0 +1,397 @@
// 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 parser = @import("../netsurf.zig");
const SessionState = @import("../env.zig").SessionState;
const Env = @import("../env.zig").Env;
const NodeList = @import("nodelist.zig").NodeList;
pub const Interfaces = .{
MutationObserver,
MutationRecord,
};
const Walker = @import("../dom/walker.zig").WalkerChildren;
const log = std.log.scoped(.events);
// WEB IDL https://dom.spec.whatwg.org/#interface-mutationobserver
pub const MutationObserver = struct {
cbk: Env.Callback,
arena: Allocator,
// List of records which were observed. When the scopeEnds, we need to
// execute our callback with it.
observed: std.ArrayListUnmanaged(*MutationRecord),
pub fn constructor(cbk: Env.Callback, state: *SessionState) !MutationObserver {
return .{
.cbk = cbk,
.observed = .{},
.arena = state.arena,
};
}
pub fn _observe(self: *MutationObserver, node: *parser.Node, options_: ?MutationObserverInit) !void {
const options = options_ orelse MutationObserverInit{};
const observer = try self.arena.create(Observer);
observer.* = .{
.node = node,
.options = options,
.mutation_observer = self,
.event_node = .{ .id = self.cbk.id, .func = Observer.handle },
};
// register node's events
if (options.childList or options.subtree) {
try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Node, node),
"DOMNodeInserted",
&observer.event_node,
false,
);
try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Node, node),
"DOMNodeRemoved",
&observer.event_node,
false,
);
}
if (options.attr()) {
try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Node, node),
"DOMAttrModified",
&observer.event_node,
false,
);
}
if (options.cdata()) {
try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Node, node),
"DOMCharacterDataModified",
&observer.event_node,
false,
);
}
if (options.subtree) {
try parser.eventTargetAddEventListener(
parser.toEventTarget(parser.Node, node),
"DOMSubtreeModified",
&observer.event_node,
false,
);
}
}
pub fn jsCallScopeEnd(self: *MutationObserver, _: anytype) void {
const record = self.observed.items;
if (record.len == 0) {
return;
}
defer self.observed.clearRetainingCapacity();
for (record) |r| {
const records = [_]MutationRecord{r.*};
var result: Env.Callback.Result = undefined;
self.cbk.tryCall(.{records}, &result) catch {
log.err("mutation observer callback error: {s}", .{result.exception});
log.debug("stack:\n{s}", .{result.stack orelse "???"});
};
}
}
// TODO
pub fn _disconnect(_: *MutationObserver) !void {
// TODO unregister listeners.
}
// TODO
pub fn _takeRecords(_: *const MutationObserver) ?[]const u8 {
return &[_]u8{};
}
};
pub const MutationRecord = struct {
type: []const u8,
target: *parser.Node,
added_nodes: NodeList = .{},
removed_nodes: NodeList = .{},
previous_sibling: ?*parser.Node = null,
next_sibling: ?*parser.Node = null,
attribute_name: ?[]const u8 = null,
attribute_namespace: ?[]const u8 = null,
old_value: ?[]const u8 = null,
pub fn get_type(self: *const MutationRecord) []const u8 {
return self.type;
}
pub fn get_addedNodes(self: *MutationRecord) *NodeList {
return &self.added_nodes;
}
pub fn get_removedNodes(self: *MutationRecord) *NodeList {
return &self.removed_nodes;
}
pub fn get_target(self: *const MutationRecord) *parser.Node {
return self.target;
}
pub fn get_attributeName(self: *const MutationRecord) ?[]const u8 {
return self.attribute_name;
}
pub fn get_attributeNamespace(self: *const MutationRecord) ?[]const u8 {
return self.attribute_namespace;
}
pub fn get_previousSibling(self: *const MutationRecord) ?*parser.Node {
return self.previous_sibling;
}
pub fn get_nextSibling(self: *const MutationRecord) ?*parser.Node {
return self.next_sibling;
}
pub fn get_oldValue(self: *const MutationRecord) ?[]const u8 {
return self.old_value;
}
};
const MutationObserverInit = struct {
childList: bool = false,
attributes: bool = false,
characterData: bool = false,
subtree: bool = false,
attributeOldValue: bool = false,
characterDataOldValue: bool = false,
// TODO
// attributeFilter: [][]const u8,
fn attr(self: MutationObserverInit) bool {
return self.attributes or self.attributeOldValue;
}
fn cdata(self: MutationObserverInit) 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,
// reference back to the MutationObserver so that we can access the arena
// and batch the mutation records.
mutation_observer: *MutationObserver,
event_node: parser.EventNode,
fn appliesTo(o: *const Observer, target: *parser.Node) bool {
// mutation on any target is always ok.
if (o.options.subtree) {
return true;
}
// if target equals node, alway ok.
if (target == o.node) {
return true;
}
// no subtree, no same target and no childlist, always noky.
if (!o.options.childList) {
return false;
}
// target must be a child of o.node
const walker = Walker{};
var next: ?*parser.Node = null;
while (true) {
next = walker.get_next(o.node, next) catch break orelse break;
if (next.? == target) {
return true;
}
}
return false;
}
fn handle(en: *parser.EventNode, event: *parser.Event) void {
const self: *Observer = @fieldParentPtr("event_node", en);
var mutation_observer = self.mutation_observer;
const node = blk: {
const event_target = parser.eventTarget(event) catch |e| {
log.err("mutation observer event target: {any}", .{e});
return;
} orelse return;
break :blk parser.eventTargetToNode(event_target);
};
if (self.appliesTo(node) == false) {
return;
}
const event_type = blk: {
const t = parser.eventType(event) catch |e| {
log.err("mutation observer event type: {any}", .{e});
return;
};
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(),
};
mutation_observer.observed.append(arena, &self.record.?) catch |err| {
log.err("mutation_observer append: {}", .{err});
};
}
var record = &self.record.?;
const mutation_event = parser.eventToMutationEvent(event);
switch (event_type) {
.DOMAttrModified => {
record.attribute_name = parser.mutationEventAttributeName(mutation_event) catch null;
if (self.options.attributeOldValue) {
record.old_value = parser.mutationEventPrevValue(mutation_event) catch null;
}
},
.DOMCharacterDataModified => {
if (self.options.characterDataOldValue) {
record.old_value = parser.mutationEventPrevValue(mutation_event) catch null;
}
},
.DOMNodeInserted => {
if (parser.mutationEventRelatedNode(mutation_event) catch null) |related_node| {
record.added_nodes.append(arena, related_node) catch |e| {
log.err("mutation event handler error: {any}", .{e});
return;
};
}
},
.DOMNodeRemoved => {
if (parser.mutationEventRelatedNode(mutation_event) catch null) |related_node| {
record.removed_nodes.append(arena, related_node) catch |e| {
log.err("mutation event handler error: {any}", .{e});
return;
};
}
},
}
}
};
const MutationEventType = enum {
DOMAttrModified,
DOMCharacterDataModified,
DOMNodeInserted,
DOMNodeRemoved,
fn recordType(self: MutationEventType) []const u8 {
return switch (self) {
.DOMAttrModified => "attributes",
.DOMCharacterDataModified => "characterData",
.DOMNodeInserted => "childList",
.DOMNodeRemoved => "childList",
};
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.MutationObserver" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "new MutationObserver(() => {}).observe(document, { childList: true });", "undefined" },
}, .{});
try runner.testCases(&.{
.{
\\ var nb = 0;
\\ var mrs;
\\ new MutationObserver((mu) => {
\\ mrs = mu;
\\ nb++;
\\ }).observe(document.firstElementChild, { attributes: true, attributeOldValue: true });
\\ document.firstElementChild.setAttribute("foo", "bar");
\\ // ignored b/c it's about another target.
\\ document.firstElementChild.firstChild.setAttribute("foo", "bar");
\\ nb;
,
"1",
},
.{ "mrs[0].type", "attributes" },
.{ "mrs[0].target == document.firstElementChild", "true" },
.{ "mrs[0].target.getAttribute('foo')", "bar" },
.{ "mrs[0].attributeName", "foo" },
.{ "mrs[0].oldValue", "null" },
}, .{});
try runner.testCases(&.{
.{
\\ var node = document.getElementById("para").firstChild;
\\ var nb2 = 0;
\\ var mrs2;
\\ new MutationObserver((mu) => {
\\ mrs2 = mu;
\\ nb2++;
\\ }).observe(node, { characterData: true, characterDataOldValue: true });
\\ node.data = "foo";
\\ nb2;
,
"1",
},
.{ "mrs2[0].type", "characterData" },
.{ "mrs2[0].target == node", "true" },
.{ "mrs2[0].target.data", "foo" },
.{ "mrs2[0].oldValue", " And" },
}, .{});
// tests that mutation observers that have a callback which trigger the
// mutation observer don't crash.
// https://github.com/lightpanda-io/browser/issues/550
try runner.testCases(&.{
.{
\\ var node = document.getElementById("para");
\\ new MutationObserver(() => {
\\ node.innerText = 'a';
\\ }).observe(document, { subtree:true,childList:true });
\\ node.innerText = "2";
,
"2",
},
}, .{});
}

View File

@@ -18,18 +18,13 @@
const std = @import("std");
const parser = @import("netsurf");
const jsruntime = @import("jsruntime");
const Case = jsruntime.test_utils.Case;
const checkCases = jsruntime.test_utils.checkCases;
const parser = @import("../netsurf.zig");
const DOMException = @import("exceptions.zig").DOMException;
// WEB IDL https://dom.spec.whatwg.org/#namednodemap
pub const NamedNodeMap = struct {
pub const Self = parser.NamedNodeMap;
pub const mem_guarantied = true;
pub const Exception = DOMException;
@@ -75,23 +70,30 @@ pub const NamedNodeMap = struct {
) !*parser.Attribute {
return try parser.namedNodeMapRemoveNamedItemNS(self, namespace, localname);
}
pub fn indexed_get(self: *parser.NamedNodeMap, index: u32, has_value: *bool) !*parser.Attribute {
return (try NamedNodeMap._item(self, index)) orelse {
has_value.* = false;
return undefined;
};
}
};
// Tests
// -----
pub fn testExecFn(
_: std.mem.Allocator,
js_env: *jsruntime.Env,
) anyerror!void {
var setItem = [_]Case{
.{ .src = "let a = document.getElementById('content').attributes", .ex = "undefined" },
.{ .src = "a.length", .ex = "1" },
.{ .src = "a.item(0)", .ex = "[object Attr]" },
.{ .src = "a.item(1)", .ex = "null" },
.{ .src = "a.getNamedItem('id')", .ex = "[object Attr]" },
.{ .src = "a.getNamedItem('foo')", .ex = "null" },
.{ .src = "a.setNamedItem(a.getNamedItem('id'))", .ex = "[object Attr]" },
};
try checkCases(js_env, &setItem);
const testing = @import("../../testing.zig");
test "Browser.DOM.NamedNodeMap" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let a = document.getElementById('content').attributes", "undefined" },
.{ "a.length", "1" },
.{ "a.item(0)", "[object Attr]" },
.{ "a.item(1)", "null" },
.{ "a.getNamedItem('id')", "[object Attr]" },
.{ "a.getNamedItem('foo')", "null" },
.{ "a.setNamedItem(a.getNamedItem('id'))", "[object Attr]" },
}, .{});
}

623
src/browser/dom/node.zig Normal file
View File

@@ -0,0 +1,623 @@
// 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 generate = @import("../../runtime/generate.zig");
const SessionState = @import("../env.zig").SessionState;
const EventTarget = @import("event_target.zig").EventTarget;
// DOM
const Attr = @import("attribute.zig").Attr;
const CData = @import("character_data.zig");
const Element = @import("element.zig").Element;
const NodeList = @import("nodelist.zig").NodeList;
const Document = @import("document.zig").Document;
const DocumentType = @import("document_type.zig").DocumentType;
const DocumentFragment = @import("document_fragment.zig").DocumentFragment;
const HTMLCollection = @import("html_collection.zig").HTMLCollection;
const HTMLCollectionIterator = @import("html_collection.zig").HTMLCollectionIterator;
const Walker = @import("walker.zig").WalkerDepthFirst;
// HTML
const HTML = @import("../html/html.zig");
const HTMLElem = @import("../html/elements.zig");
// Node interfaces
pub const Interfaces = .{
Attr,
CData.CharacterData,
CData.Interfaces,
Element,
Document,
DocumentType,
DocumentFragment,
HTMLCollection,
HTMLCollectionIterator,
HTML.Interfaces,
};
pub const Union = generate.Union(Interfaces);
// Node implementation
pub const Node = struct {
pub const Self = parser.Node;
pub const prototype = *EventTarget;
pub const subtype = .node;
pub fn toInterface(node: *parser.Node) !Union {
return switch (try parser.nodeType(node)) {
.element => try HTMLElem.toInterface(
Union,
@as(*parser.Element, @ptrCast(node)),
),
.comment => .{ .Comment = @as(*parser.Comment, @ptrCast(node)) },
.text => .{ .Text = @as(*parser.Text, @ptrCast(node)) },
.cdata_section => .{ .CDATASection = @as(*parser.CDATASection, @ptrCast(node)) },
.processing_instruction => .{ .ProcessingInstruction = @as(*parser.ProcessingInstruction, @ptrCast(node)) },
.document => .{ .HTMLDocument = @as(*parser.DocumentHTML, @ptrCast(node)) },
.document_type => .{ .DocumentType = @as(*parser.DocumentType, @ptrCast(node)) },
.attribute => .{ .Attr = @as(*parser.Attribute, @ptrCast(node)) },
.document_fragment => .{ .DocumentFragment = @as(*parser.DocumentFragment, @ptrCast(node)) },
else => @panic("node type not handled"), // TODO
};
}
// class attributes
pub const _ELEMENT_NODE = @intFromEnum(parser.NodeType.element);
pub const _ATTRIBUTE_NODE = @intFromEnum(parser.NodeType.attribute);
pub const _TEXT_NODE = @intFromEnum(parser.NodeType.text);
pub const _CDATA_SECTION_NODE = @intFromEnum(parser.NodeType.cdata_section);
pub const _PROCESSING_INSTRUCTION_NODE = @intFromEnum(parser.NodeType.processing_instruction);
pub const _COMMENT_NODE = @intFromEnum(parser.NodeType.comment);
pub const _DOCUMENT_NODE = @intFromEnum(parser.NodeType.document);
pub const _DOCUMENT_TYPE_NODE = @intFromEnum(parser.NodeType.document_type);
pub const _DOCUMENT_FRAGMENT_NODE = @intFromEnum(parser.NodeType.document_fragment);
// These 3 are deprecated, but both Chrome and Firefox still expose them
pub const _ENTITY_REFERENCE_NODE = @intFromEnum(parser.NodeType.entity_reference);
pub const _ENTITY_NODE = @intFromEnum(parser.NodeType.entity);
pub const _NOTATION_NODE = @intFromEnum(parser.NodeType.notation);
// JS funcs
// --------
// Read-only attributes
pub fn get_firstChild(self: *parser.Node) !?Union {
const res = try parser.nodeFirstChild(self);
if (res == null) {
return null;
}
return try Node.toInterface(res.?);
}
pub fn get_lastChild(self: *parser.Node) !?Union {
const res = try parser.nodeLastChild(self);
if (res == null) {
return null;
}
return try Node.toInterface(res.?);
}
pub fn get_nextSibling(self: *parser.Node) !?Union {
const res = try parser.nodeNextSibling(self);
if (res == null) {
return null;
}
return try Node.toInterface(res.?);
}
pub fn get_previousSibling(self: *parser.Node) !?Union {
const res = try parser.nodePreviousSibling(self);
if (res == null) {
return null;
}
return try Node.toInterface(res.?);
}
pub fn get_parentNode(self: *parser.Node) !?Union {
const res = try parser.nodeParentNode(self);
if (res == null) {
return null;
}
return try Node.toInterface(res.?);
}
pub fn get_parentElement(self: *parser.Node) !?HTMLElem.Union {
const res = try parser.nodeParentElement(self);
if (res == null) {
return null;
}
return try HTMLElem.toInterface(HTMLElem.Union, @as(*parser.Element, @ptrCast(res.?)));
}
pub fn get_nodeName(self: *parser.Node) ![]const u8 {
return try parser.nodeName(self);
}
pub fn get_nodeType(self: *parser.Node) !u8 {
return @intFromEnum(try parser.nodeType(self));
}
pub fn get_ownerDocument(self: *parser.Node) !?*parser.DocumentHTML {
const res = try parser.nodeOwnerDocument(self);
if (res == null) {
return null;
}
return @as(*parser.DocumentHTML, @ptrCast(res.?));
}
pub fn get_isConnected(self: *parser.Node) !bool {
// TODO: handle Shadow DOM
if (try parser.nodeType(self) == .document) {
return true;
}
return try Node.get_parentNode(self) != null;
}
// Read/Write attributes
pub fn get_nodeValue(self: *parser.Node) !?[]const u8 {
return try parser.nodeValue(self);
}
pub fn set_nodeValue(self: *parser.Node, data: []u8) !void {
try parser.nodeSetValue(self, data);
}
pub fn get_textContent(self: *parser.Node) !?[]const u8 {
return try parser.nodeTextContent(self);
}
pub fn set_textContent(self: *parser.Node, data: []u8) !void {
return try parser.nodeSetTextContent(self, data);
}
// Methods
pub fn _appendChild(self: *parser.Node, child: *parser.Node) !Union {
// TODO: DocumentFragment special case
const res = try parser.nodeAppendChild(self, child);
return try Node.toInterface(res);
}
pub fn _cloneNode(self: *parser.Node, deep: ?bool) !Union {
const clone = try parser.nodeCloneNode(self, deep orelse false);
return try Node.toInterface(clone);
}
pub fn _compareDocumentPosition(self: *parser.Node, other: *parser.Node) !u32 {
if (self == other) return 0;
const docself = try parser.nodeOwnerDocument(self);
const docother = try parser.nodeOwnerDocument(other);
// Both are in different document.
if (docself == null or docother == null or docother.? != docself.?) {
return @intFromEnum(parser.DocumentPosition.disconnected);
}
// TODO Both are in a different trees in the same document.
const w = Walker{};
var next: ?*parser.Node = null;
// Is other a descendant of self?
while (true) {
next = try w.get_next(self, next) orelse break;
if (other == next) {
return @intFromEnum(parser.DocumentPosition.following) +
@intFromEnum(parser.DocumentPosition.contained_by);
}
}
// Is self a descendant of other?
next = null;
while (true) {
next = try w.get_next(other, next) orelse break;
if (self == next) {
return @intFromEnum(parser.DocumentPosition.contains) +
@intFromEnum(parser.DocumentPosition.preceding);
}
}
next = null;
while (true) {
next = try w.get_next(parser.documentToNode(docself.?), next) orelse break;
if (other == next) {
// other precedes self.
return @intFromEnum(parser.DocumentPosition.preceding);
}
if (self == next) {
// other follows self.
return @intFromEnum(parser.DocumentPosition.following);
}
}
return 0;
}
pub fn _contains(self: *parser.Node, other: *parser.Node) !bool {
return try parser.nodeContains(self, other);
}
pub fn _getRootNode(self: *parser.Node) !?HTMLElem.Union {
// TODO return thiss shadow-including root if options["composed"] is true
const res = try parser.nodeOwnerDocument(self);
if (res == null) {
return null;
}
return try HTMLElem.toInterface(HTMLElem.Union, @as(*parser.Element, @ptrCast(res.?)));
}
pub fn _hasChildNodes(self: *parser.Node) !bool {
return try parser.nodeHasChildNodes(self);
}
pub fn get_childNodes(self: *parser.Node, state: *SessionState) !NodeList {
const allocator = state.arena;
var list: NodeList = .{};
var n = try parser.nodeFirstChild(self) orelse return list;
while (true) {
try list.append(allocator, n);
n = try parser.nodeNextSibling(n) orelse return list;
}
}
pub fn _insertBefore(self: *parser.Node, new_node: *parser.Node, ref_node: *parser.Node) !*parser.Node {
return try parser.nodeInsertBefore(self, new_node, ref_node);
}
pub fn _isDefaultNamespace(self: *parser.Node, namespace: ?[]const u8) !bool {
return try parser.nodeIsDefaultNamespace(self, namespace);
}
pub fn _isEqualNode(self: *parser.Node, other: *parser.Node) !bool {
// TODO: other is not an optional parameter, but can be null.
return try parser.nodeIsEqualNode(self, other);
}
pub fn _isSameNode(self: *parser.Node, other: *parser.Node) !bool {
// TODO: other is not an optional parameter, but can be null.
// NOTE: there is no need to use isSameNode(); instead use the === strict equality operator
return try parser.nodeIsSameNode(self, other);
}
pub fn _lookupPrefix(self: *parser.Node, namespace: ?[]const u8) !?[]const u8 {
// TODO: other is not an optional parameter, but can be null.
if (namespace == null) {
return null;
}
if (std.mem.eql(u8, namespace.?, "")) {
return null;
}
return try parser.nodeLookupPrefix(self, namespace.?);
}
pub fn _lookupNamespaceURI(self: *parser.Node, prefix: ?[]const u8) !?[]const u8 {
// TODO: other is not an optional parameter, but can be null.
return try parser.nodeLookupNamespaceURI(self, prefix);
}
pub fn _normalize(self: *parser.Node) !void {
return try parser.nodeNormalize(self);
}
pub fn _removeChild(self: *parser.Node, child: *parser.Node) !Union {
const res = try parser.nodeRemoveChild(self, child);
return try Node.toInterface(res);
}
pub fn _replaceChild(self: *parser.Node, new_child: *parser.Node, old_child: *parser.Node) !Union {
const res = try parser.nodeReplaceChild(self, new_child, old_child);
return try Node.toInterface(res);
}
// Check if the hierarchy node tree constraints are respected.
// For now, it checks only if new nodes are not self.
// TODO implements the others contraints.
// see https://dom.spec.whatwg.org/#concept-node-tree
pub fn hierarchy(self: *parser.Node, nodes: []const *parser.Node) !bool {
if (nodes.len == 0) return true;
for (nodes) |node| if (self == node) return false;
return true;
}
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
// function must accept either node or string.
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
pub fn prepend(self: *parser.Node, nodes: []const *parser.Node) !void {
if (nodes.len == 0) return;
// check hierarchy
if (!try hierarchy(self, nodes)) return parser.DOMError.HierarchyRequest;
const first = try parser.nodeFirstChild(self);
if (first == null) {
for (nodes) |node| {
_ = try parser.nodeAppendChild(self, node);
}
return;
}
for (nodes) |node| {
_ = try parser.nodeInsertBefore(self, node, first.?);
}
}
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
// function must accept either node or string.
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
pub fn append(self: *parser.Node, nodes: []const *parser.Node) !void {
if (nodes.len == 0) return;
// check hierarchy
if (!try hierarchy(self, nodes)) return parser.DOMError.HierarchyRequest;
for (nodes) |node| {
_ = try parser.nodeAppendChild(self, node);
}
}
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
// function must accept either node or string.
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
pub fn replaceChildren(self: *parser.Node, nodes: []const *parser.Node) !void {
if (nodes.len == 0) return;
// check hierarchy
if (!try hierarchy(self, nodes)) return parser.DOMError.HierarchyRequest;
// remove existing children
try removeChildren(self);
// add new children
for (nodes) |node| {
_ = try parser.nodeAppendChild(self, node);
}
}
pub fn removeChildren(self: *parser.Node) !void {
if (!try parser.nodeHasChildNodes(self)) return;
const children = try parser.nodeGetChildNodes(self);
const ln = try parser.nodeListLength(children);
var i: u32 = 0;
while (i < ln) {
defer i += 1;
// we always retrieve the 0 index child on purpose: libdom nodelist
// are dynamic. So the next child to remove is always as pos 0.
const child = try parser.nodeListItem(children, 0) orelse continue;
_ = try parser.nodeRemoveChild(self, child);
}
}
pub fn deinit(_: *parser.Node, _: std.mem.Allocator) void {}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.node" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
{
var err_out: ?[]const u8 = null;
try runner.exec(
\\ function trimAndReplace(str) {
\\ str = str.replace(/(\r\n|\n|\r)/gm,'');
\\ str = str.replace(/\s+/g, ' ');
\\ str = str.trim();
\\ return str;
\\ }
, "trimAndReplace", &err_out);
}
try runner.testCases(&.{
.{ "document.body.compareDocumentPosition(document.firstChild); ", "10" },
.{ "document.getElementById(\"para-empty\").compareDocumentPosition(document.getElementById(\"content\"));", "10" },
.{ "document.getElementById(\"content\").compareDocumentPosition(document.getElementById(\"para-empty\"));", "20" },
.{ "document.getElementById(\"link\").compareDocumentPosition(document.getElementById(\"link\"));", "0" },
.{ "document.getElementById(\"para-empty\").compareDocumentPosition(document.getElementById(\"link\"));", "2" },
.{ "document.getElementById(\"link\").compareDocumentPosition(document.getElementById(\"para-empty\"));", "4" },
}, .{});
try runner.testCases(&.{
.{ "document.getElementById('content').getRootNode().__proto__.constructor.name", "HTMLDocument" },
}, .{});
try runner.testCases(&.{
// for next test cases
.{ "let content = document.getElementById('content')", "undefined" },
.{ "let link = document.getElementById('link')", "undefined" },
.{ "let first_child = content.firstChild.nextSibling", "undefined" }, // nextSibling because of line return \n
.{ "let body_first_child = document.body.firstChild", "undefined" },
.{ "body_first_child.localName", "div" },
.{ "body_first_child.__proto__.constructor.name", "HTMLDivElement" },
.{ "document.getElementById('para-empty').firstChild.firstChild", "null" },
}, .{});
try runner.testCases(&.{
.{ "let last_child = content.lastChild.previousSibling", "undefined" }, // previousSibling because of line return \n
.{ "last_child.__proto__.constructor.name", "Comment" },
}, .{});
try runner.testCases(&.{
.{ "let next_sibling = link.nextSibling.nextSibling", "undefined" },
.{ "next_sibling.localName", "p" },
.{ "next_sibling.__proto__.constructor.name", "HTMLParagraphElement" },
.{ "content.nextSibling.nextSibling", "null" },
}, .{});
try runner.testCases(&.{
.{ "let prev_sibling = document.getElementById('para-empty').previousSibling.previousSibling", "undefined" },
.{ "prev_sibling.localName", "a" },
.{ "prev_sibling.__proto__.constructor.name", "HTMLAnchorElement" },
.{ "content.previousSibling", "null" },
}, .{});
try runner.testCases(&.{
.{ "let parent = document.getElementById('para').parentElement", "undefined" },
.{ "parent.localName", "div" },
.{ "parent.__proto__.constructor.name", "HTMLDivElement" },
.{ "let h = content.parentElement.parentElement", "undefined" },
.{ "h.parentElement", "null" },
.{ "h.parentNode.__proto__.constructor.name", "HTMLDocument" },
}, .{});
try runner.testCases(&.{
.{ "first_child.nodeName === 'A'", "true" },
.{ "link.firstChild.nodeName === '#text'", "true" },
.{ "last_child.nodeName === '#comment'", "true" },
.{ "document.nodeName === '#document'", "true" },
}, .{});
try runner.testCases(&.{
.{ "first_child.nodeType === 1", "true" },
.{ "link.firstChild.nodeType === 3", "true" },
.{ "last_child.nodeType === 8", "true" },
.{ "document.nodeType === 9", "true" },
}, .{});
try runner.testCases(&.{
.{ "let owner = content.ownerDocument", "undefined" },
.{ "owner.__proto__.constructor.name", "HTMLDocument" },
.{ "document.ownerDocument", "null" },
.{ "let owner2 = document.createElement('div').ownerDocument", "undefined" },
.{ "owner2.__proto__.constructor.name", "HTMLDocument" },
}, .{});
try runner.testCases(&.{
.{ "content.isConnected", "true" },
.{ "document.isConnected", "true" },
.{ "document.createElement('div').isConnected", "false" },
}, .{});
try runner.testCases(&.{
.{ "last_child.nodeValue === 'comment'", "true" },
.{ "link.nodeValue === null", "true" },
.{ "let text = link.firstChild", "undefined" },
.{ "text.nodeValue === 'OK'", "true" },
.{ "text.nodeValue = 'OK modified'", "OK modified" },
.{ "text.nodeValue === 'OK modified'", "true" },
.{ "link.nodeValue = 'nothing'", "nothing" },
}, .{});
try runner.testCases(&.{
.{ "text.textContent === 'OK modified'", "true" },
.{ "trimAndReplace(content.textContent) === 'OK modified And'", "true" },
.{ "text.textContent = 'OK'", "OK" },
.{ "text.textContent", "OK" },
.{ "trimAndReplace(document.getElementById('para-empty').textContent)", "" },
.{ "document.getElementById('para-empty').textContent = 'OK'", "OK" },
.{ "document.getElementById('para-empty').firstChild.nodeName === '#text'", "true" },
}, .{});
try runner.testCases(&.{
.{ "let append = document.createElement('h1')", "undefined" },
.{ "content.appendChild(append).toString()", "[object HTMLHeadingElement]" },
.{ "content.lastChild.__proto__.constructor.name", "HTMLHeadingElement" },
.{ "content.appendChild(link).toString()", "[object HTMLAnchorElement]" },
}, .{});
try runner.testCases(&.{
.{ "let clone = link.cloneNode()", "undefined" },
.{ "clone.toString()", "[object HTMLAnchorElement]" },
.{ "clone.parentNode === null", "true" },
.{ "clone.firstChild === null", "true" },
.{ "let clone_deep = link.cloneNode(true)", "undefined" },
.{ "clone_deep.firstChild.nodeName === '#text'", "true" },
}, .{});
try runner.testCases(&.{
.{ "link.contains(text)", "true" },
.{ "text.contains(link)", "false" },
}, .{});
try runner.testCases(&.{
.{ "link.hasChildNodes()", "true" },
.{ "text.hasChildNodes()", "false" },
}, .{});
try runner.testCases(&.{
.{ "link.childNodes.length", "1" },
.{ "text.childNodes.length", "0" },
}, .{});
try runner.testCases(&.{
.{ "let insertBefore = document.createElement('a')", "undefined" },
.{ "link.insertBefore(insertBefore, text) !== undefined", "true" },
.{ "link.firstChild.localName === 'a'", "true" },
}, .{});
try runner.testCases(&.{
// TODO: does not seems to work
// .{ "link.isDefaultNamespace('')", "true" },
.{ "link.isDefaultNamespace('false')", "false" },
}, .{});
try runner.testCases(&.{
.{ "let equal1 = document.createElement('a')", "undefined" },
.{ "let equal2 = document.createElement('a')", "undefined" },
.{ "equal1.textContent = 'is equal'", "is equal" },
.{ "equal2.textContent = 'is equal'", "is equal" },
// TODO: does not seems to work
// .{ "equal1.isEqualNode(equal2)", "true" },
}, .{});
try runner.testCases(&.{
.{ "document.body.isSameNode(document.body)", "true" },
}, .{});
try runner.testCases(&.{
// TODO: no test
.{ "link.normalize()", "undefined" },
}, .{});
try runner.testCases(&.{
.{ "content.removeChild(append) !== undefined", "true" },
.{ "last_child.__proto__.constructor.name !== 'HTMLHeadingElement'", "true" },
}, .{});
try runner.testCases(&.{
.{ "let replace = document.createElement('div')", "undefined" },
.{ "link.replaceChild(replace, insertBefore) !== undefined", "true" },
}, .{});
try runner.testCases(&.{
.{ "Node.ELEMENT_NODE", "1" },
.{ "Node.ATTRIBUTE_NODE", "2" },
.{ "Node.TEXT_NODE", "3" },
.{ "Node.CDATA_SECTION_NODE", "4" },
.{ "Node.PROCESSING_INSTRUCTION_NODE", "7" },
.{ "Node.COMMENT_NODE", "8" },
.{ "Node.DOCUMENT_NODE", "9" },
.{ "Node.DOCUMENT_TYPE_NODE", "10" },
.{ "Node.DOCUMENT_FRAGMENT_NODE", "11" },
.{ "Node.ENTITY_REFERENCE_NODE", "5" },
.{ "Node.ENTITY_NODE", "6" },
.{ "Node.NOTATION_NODE", "12" },
}, .{});
}

View File

@@ -0,0 +1,200 @@
// 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 JsThis = @import("../env.zig").JsThis;
const Callback = @import("../env.zig").Callback;
const NodeUnion = @import("node.zig").Union;
const Node = @import("node.zig").Node;
const U32Iterator = @import("../iterator/iterator.zig").U32Iterator;
const log = std.log.scoped(.nodelist);
const DOMException = @import("exceptions.zig").DOMException;
pub const Interfaces = .{
NodeListIterator,
NodeList,
};
pub const NodeListIterator = struct {
coll: *NodeList,
index: u32 = 0,
pub const Return = struct {
value: ?NodeUnion,
done: bool,
};
pub fn _next(self: *NodeListIterator) !Return {
const e = try self.coll._item(self.index);
if (e == null) {
return Return{
.value = null,
.done = true,
};
}
self.index += 1;
return Return{
.value = e,
.done = false,
};
}
};
pub const NodeListEntriesIterator = struct {
coll: *NodeList,
index: u32 = 0,
pub const Return = struct {
value: ?NodeUnion,
done: bool,
};
pub fn _next(self: *NodeListEntriesIterator) !Return {
const e = try self.coll._item(self.index);
if (e == null) {
return Return{
.value = null,
.done = true,
};
}
self.index += 1;
return Return{
.value = e,
.done = false,
};
}
};
// Nodelist is implemented in pure Zig b/c libdom's NodeList doesn't allow to
// append nodes.
// WEB IDL https://dom.spec.whatwg.org/#nodelist
//
// TODO: a Nodelist can be either static or live. But the current
// implementation allows only static nodelist.
// see https://dom.spec.whatwg.org/#old-style-collections
pub const NodeList = struct {
pub const Exception = DOMException;
const NodesArrayList = std.ArrayListUnmanaged(*parser.Node);
nodes: NodesArrayList = .{},
pub fn deinit(self: *NodeList, alloc: std.mem.Allocator) void {
// TODO unref all nodes
self.nodes.deinit(alloc);
}
pub fn append(self: *NodeList, alloc: std.mem.Allocator, node: *parser.Node) !void {
try self.nodes.append(alloc, node);
}
pub fn get_length(self: *NodeList) u32 {
return @intCast(self.nodes.items.len);
}
pub fn _item(self: *const NodeList, index: u32) !?NodeUnion {
if (index >= self.nodes.items.len) {
return null;
}
const n = self.nodes.items[index];
return try Node.toInterface(n);
}
// This code works, but it's _MUCH_ slower than using postAttach. The benefit
// of this version, is that it's "live"..but we're talking many orders of
// magnitude slower.
//
// You can test it by commenting out `postAttach`, uncommenting this and
// running:
// zig build wpt -- tests/wpt/dom/nodes/NodeList-static-length-getter-tampered-indexOf-1.html
//
// I think this _is_ the right way to do it, but I must be doing something
// wrong to make it so slow.
// pub fn indexed_get(self: *const NodeList, index: u32, has_value: *bool) !?NodeUnion {
// return (try self._item(index)) orelse {
// has_value.* = false;
// return null;
// };
// }
pub fn _forEach(self: *NodeList, cbk: Callback) !void { // TODO handle thisArg
for (self.nodes.items, 0..) |n, i| {
const ii: u32 = @intCast(i);
var result: Callback.Result = undefined;
cbk.tryCall(.{ n, ii, self }, &result) catch {
log.err("callback error: {s}", .{result.exception});
log.debug("stack:\n{s}", .{result.stack orelse "???"});
};
}
}
pub fn _keys(self: *NodeList) U32Iterator {
return .{
.length = self.get_length(),
};
}
pub fn _values(self: *NodeList) NodeListIterator {
return .{
.coll = self,
};
}
pub fn _symbol_iterator(self: *NodeList) NodeListIterator {
return self._values();
}
// TODO entries() https://developer.mozilla.org/en-US/docs/Web/API/NodeList/entries
pub fn postAttach(self: *NodeList, js_this: JsThis) !void {
const len = self.get_length();
for (0..len) |i| {
const node = try self._item(@intCast(i)) orelse unreachable;
try js_this.setIndex(i, node);
}
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.NodeList" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let list = document.getElementById('content').childNodes", "undefined" },
.{ "list.length", "9" },
.{ "list[0].__proto__.constructor.name", "Text" },
.{
\\ let i = 0;
\\ list.forEach(function (n, idx) {
\\ i += idx;
\\ });
\\ i;
,
"36",
},
}, .{});
}

View File

@@ -0,0 +1,116 @@
// 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 Node = @import("node.zig").Node;
const SessionState = @import("../env.zig").SessionState;
// https://dom.spec.whatwg.org/#processinginstruction
pub const ProcessingInstruction = struct {
pub const Self = parser.ProcessingInstruction;
// TODO for libdom processing instruction inherit from node.
// But the spec says it must inherit from CDATA.
pub const prototype = *Node;
pub const subtype = .node;
pub fn get_target(self: *parser.ProcessingInstruction) ![]const u8 {
// libdom stores the ProcessingInstruction target in the node's name.
return try parser.nodeName(parser.processingInstructionToNode(self));
}
// There's something wrong when we try to clone a ProcessInstruction normally.
// The resulting object can't be cast back into a node (it crashes). This is
// a simple workaround.
pub fn _cloneNode(self: *parser.ProcessingInstruction, _: ?bool, state: *SessionState) !*parser.ProcessingInstruction {
return try parser.documentCreateProcessingInstruction(
@ptrCast(state.document),
try get_target(self),
(try get_data(self)) orelse "",
);
}
pub fn get_data(self: *parser.ProcessingInstruction) !?[]const u8 {
return try parser.nodeValue(parser.processingInstructionToNode(self));
}
pub fn set_data(self: *parser.ProcessingInstruction, data: []u8) !void {
try parser.nodeSetValue(parser.processingInstructionToNode(self), data);
}
// netsurf's ProcessInstruction doesn't implement the dom_node_get_attributes
// and thus will crash if we try to call nodeIsEqualNode.
pub fn _isEqualNode(self: *parser.ProcessingInstruction, other_node: *parser.Node) !bool {
if (try parser.nodeType(other_node) != .processing_instruction) {
return false;
}
const other: *parser.ProcessingInstruction = @ptrCast(other_node);
if (std.mem.eql(u8, try get_target(self), try get_target(other)) == false) {
return false;
}
{
const self_data = try get_data(self);
const other_data = try get_data(other);
if (self_data == null and other_data != null) {
return false;
}
if (self_data != null and other_data == null) {
return false;
}
if (std.mem.eql(u8, self_data.?, other_data.?) == false) {
return false;
}
}
return true;
}
};
const testing = @import("../../testing.zig");
test "Browser.DOM.ProcessingInstruction" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let pi = document.createProcessingInstruction('foo', 'bar')", "undefined" },
.{ "pi.target", "foo" },
.{ "pi.data", "bar" },
.{ "pi.data = 'foo'", "foo" },
.{ "pi.data", "foo" },
.{ "let pi2 = pi.cloneNode()", "undefined" },
.{ "pi2.nodeType", "7" },
}, .{});
try runner.testCases(&.{
.{ "let pi11 = document.createProcessingInstruction('target1', 'data1');", "undefined" },
.{ "let pi12 = document.createProcessingInstruction('target2', 'data2');", "undefined" },
.{ "let pi13 = document.createProcessingInstruction('target1', 'data1');", "undefined" },
.{ "pi11.isEqualNode(pi11)", "true" },
.{ "pi11.isEqualNode(pi13)", "true" },
.{ "pi11.isEqualNode(pi12)", "false" },
.{ "pi12.isEqualNode(pi13)", "false" },
.{ "pi11.isEqualNode(document)", "false" },
.{ "document.isEqualNode(pi11)", "false" },
}, .{});
}

View File

@@ -16,33 +16,25 @@
// 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 jsruntime = @import("jsruntime");
const Case = jsruntime.test_utils.Case;
const checkCases = jsruntime.test_utils.checkCases;
const generate = @import("../generate.zig");
const parser = @import("netsurf");
const parser = @import("../netsurf.zig");
const SessionState = @import("../env.zig").SessionState;
const CharacterData = @import("character_data.zig").CharacterData;
const CDATASection = @import("cdata_section.zig").CDATASection;
const UserContext = @import("../user_context.zig").UserContext;
// Text interfaces
pub const Interfaces = generate.Tuple(.{
pub const Interfaces = .{
CDATASection,
});
};
pub const Text = struct {
pub const Self = parser.Text;
pub const prototype = *CharacterData;
pub const mem_guarantied = true;
pub const subtype = .node;
pub fn constructor(userctx: UserContext, data: ?[]const u8) !*parser.Text {
pub fn constructor(data: ?[]const u8, state: *const SessionState) !*parser.Text {
return parser.documentCreateTextNode(
parser.documentHTMLToDocument(userctx.document),
parser.documentHTMLToDocument(state.document.?),
data orelse "",
);
}
@@ -67,30 +59,28 @@ pub const Text = struct {
// Tests
// -----
pub fn testExecFn(
_: std.mem.Allocator,
js_env: *jsruntime.Env,
) anyerror!void {
var constructor = [_]Case{
.{ .src = "let t = new Text('foo')", .ex = "undefined" },
.{ .src = "t.data", .ex = "foo" },
const testing = @import("../../testing.zig");
test "Browser.DOM.Text" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
.{ .src = "let emptyt = new Text()", .ex = "undefined" },
.{ .src = "emptyt.data", .ex = "" },
};
try checkCases(js_env, &constructor);
try runner.testCases(&.{
.{ "let t = new Text('foo')", "undefined" },
.{ "t.data", "foo" },
var get_whole_text = [_]Case{
.{ .src = "let text = document.getElementById('link').firstChild", .ex = "undefined" },
.{ .src = "text.wholeText === 'OK'", .ex = "true" },
};
try checkCases(js_env, &get_whole_text);
.{ "let emptyt = new Text()", "undefined" },
.{ "emptyt.data", "" },
}, .{});
var split_text = [_]Case{
.{ .src = "text.data = 'OK modified'", .ex = "OK modified" },
.{ .src = "let split = text.splitText('OK'.length)", .ex = "undefined" },
.{ .src = "split.data === ' modified'", .ex = "true" },
.{ .src = "text.data === 'OK'", .ex = "true" },
};
try checkCases(js_env, &split_text);
try runner.testCases(&.{
.{ "let text = document.getElementById('link').firstChild", "undefined" },
.{ "text.wholeText === 'OK'", "true" },
}, .{});
try runner.testCases(&.{
.{ "text.data = 'OK modified'", "OK modified" },
.{ "let split = text.splitText('OK'.length)", "undefined" },
.{ "split.data === ' modified'", "true" },
.{ "text.data === 'OK'", "true" },
}, .{});
}

View File

@@ -0,0 +1,243 @@
// 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 iterator = @import("../iterator/iterator.zig");
const Callback = @import("../env.zig").Callback;
const JsObject = @import("../env.zig").JsObject;
const DOMException = @import("exceptions.zig").DOMException;
const log = std.log.scoped(.token_list);
pub const Interfaces = .{
DOMTokenList,
DOMTokenListIterable,
TokenListEntriesIterator,
TokenListEntriesIterator.Iterable,
};
// https://dom.spec.whatwg.org/#domtokenlist
pub const DOMTokenList = struct {
pub const Self = parser.TokenList;
pub const Exception = DOMException;
pub fn get_length(self: *parser.TokenList) !u32 {
return parser.tokenListGetLength(self);
}
pub fn _item(self: *parser.TokenList, index: u32) !?[]const u8 {
return parser.tokenListItem(self, index);
}
pub fn _contains(self: *parser.TokenList, token: []const u8) !bool {
return parser.tokenListContains(self, token);
}
pub fn _add(self: *parser.TokenList, tokens: []const []const u8) !void {
for (tokens) |token| {
try parser.tokenListAdd(self, token);
}
}
pub fn _remove(self: *parser.TokenList, tokens: []const []const u8) !void {
for (tokens) |token| {
try parser.tokenListRemove(self, token);
}
}
/// If token is the empty string, then throw a "SyntaxError" DOMException.
/// If token contains any ASCII whitespace, then throw an
/// "InvalidCharacterError" DOMException.
fn validateToken(token: []const u8) !void {
if (token.len == 0) {
return parser.DOMError.Syntax;
}
for (token) |c| {
if (std.ascii.isWhitespace(c)) return parser.DOMError.InvalidCharacter;
}
}
pub fn _toggle(self: *parser.TokenList, token: []const u8, force: ?bool) !bool {
try validateToken(token);
const exists = try parser.tokenListContains(self, token);
if (exists) {
if (force == null or force.? == false) {
try parser.tokenListRemove(self, token);
return false;
}
return true;
}
if (force == null or force.? == true) {
try parser.tokenListAdd(self, token);
return true;
}
return false;
}
pub fn _replace(self: *parser.TokenList, token: []const u8, new: []const u8) !bool {
try validateToken(token);
try validateToken(new);
const exists = try parser.tokenListContains(self, token);
if (!exists) return false;
try parser.tokenListRemove(self, token);
try parser.tokenListAdd(self, new);
return true;
}
// TODO to implement.
pub fn _supports(_: *parser.TokenList, token: []const u8) !bool {
try validateToken(token);
return error.TypeError;
}
pub fn get_value(self: *parser.TokenList) !?[]const u8 {
return (try parser.tokenListGetValue(self)) orelse "";
}
pub fn set_value(self: *parser.TokenList, value: []const u8) !void {
return parser.tokenListSetValue(self, value);
}
pub fn _toString(self: *parser.TokenList) ![]const u8 {
return (try get_value(self)) orelse "";
}
pub fn _keys(self: *parser.TokenList) !iterator.U32Iterator {
return .{ .length = try get_length(self) };
}
pub fn _values(self: *parser.TokenList) DOMTokenListIterable {
return DOMTokenListIterable.init(.{ .token_list = self });
}
pub fn _entries(self: *parser.TokenList) TokenListEntriesIterator {
return TokenListEntriesIterator.init(.{ .token_list = self });
}
pub fn _symbol_iterator(self: *parser.TokenList) DOMTokenListIterable {
return _values(self);
}
// TODO handle thisArg
pub fn _forEach(self: *parser.TokenList, cbk: Callback, this_arg: JsObject) !void {
var entries = _entries(self);
while (try entries._next()) |entry| {
var result: Callback.Result = undefined;
cbk.tryCallWithThis(this_arg, .{ entry.@"1", entry.@"0", self }, &result) catch {
log.err("callback error: {s}", .{result.exception});
log.debug("stack:\n{s}", .{result.stack orelse "???"});
};
}
}
};
const DOMTokenListIterable = iterator.Iterable(Iterator, "DOMTokenListIterable");
const TokenListEntriesIterator = iterator.NumericEntries(Iterator, "TokenListEntriesIterator");
pub const Iterator = struct {
index: u32 = 0,
token_list: *parser.TokenList,
// used when wrapped in an iterator.NumericEntries
pub const Error = parser.DOMError;
pub fn _next(self: *Iterator) !?[]const u8 {
const index = self.index;
self.index = index + 1;
return DOMTokenList._item(self.token_list, index);
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.DOM.TokenList" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let gs = document.getElementById('para-empty')", "undefined" },
.{ "let cl = gs.classList", "undefined" },
.{ "gs.className", "ok empty" },
.{ "cl.value", "ok empty" },
.{ "cl.length", "2" },
.{ "gs.className = 'foo bar baz'", "foo bar baz" },
.{ "gs.className", "foo bar baz" },
.{ "cl.length", "3" },
.{ "gs.className = 'ok empty'", "ok empty" },
.{ "cl.length", "2" },
}, .{});
try runner.testCases(&.{
.{ "let cl2 = gs.classList", "undefined" },
.{ "cl2.length", "2" },
.{ "cl2.item(0)", "ok" },
.{ "cl2.item(1)", "empty" },
.{ "cl2.contains('ok')", "true" },
.{ "cl2.contains('nok')", "false" },
.{ "cl2.add('foo', 'bar', 'baz')", "undefined" },
.{ "cl2.length", "5" },
.{ "cl2.remove('foo', 'bar', 'baz')", "undefined" },
.{ "cl2.length", "2" },
}, .{});
try runner.testCases(&.{
.{ "let cl3 = gs.classList", "undefined" },
.{ "cl3.toggle('ok')", "false" },
.{ "cl3.toggle('ok')", "true" },
.{ "cl3.length", "2" },
}, .{});
try runner.testCases(&.{
.{ "let cl4 = gs.classList", "undefined" },
.{ "cl4.replace('ok', 'nok')", "true" },
.{ "cl4.value", "empty nok" },
.{ "cl4.replace('nok', 'ok')", "true" },
.{ "cl4.value", "empty ok" },
}, .{});
try runner.testCases(&.{
.{ "let cl5 = gs.classList", "undefined" },
.{ "let keys = [...cl5.keys()]", "undefined" },
.{ "keys.length", "2" },
.{ "keys[0]", "0" },
.{ "keys[1]", "1" },
.{ "let values = [...cl5.values()]", "undefined" },
.{ "values.length", "2" },
.{ "values[0]", "empty" },
.{ "values[1]", "ok" },
.{ "let entries = [...cl5.entries()]", "undefined" },
.{ "entries.length", "2" },
.{ "entries[0]", "0,empty" },
.{ "entries[1]", "1,ok" },
}, .{});
try runner.testCases(&.{
.{ "let cl6 = gs.classList", "undefined" },
.{ "cl6.value = 'a b ccc'", "a b ccc" },
.{ "cl6.value", "a b ccc" },
.{ "cl6.toString()", "a b ccc" },
}, .{});
}

View File

@@ -16,9 +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 parser = @import("netsurf");
const parser = @import("../netsurf.zig");
pub const Walker = union(enum) {
walkerDepthFirst: WalkerDepthFirst,

View File

@@ -17,90 +17,94 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const File = std.fs.File;
const parser = @import("netsurf");
const Walker = @import("../dom/walker.zig").WalkerChildren;
const parser = @import("netsurf.zig");
const Walker = @import("dom/walker.zig").WalkerChildren;
// writer must be a std.io.Writer
pub fn writeHTML(doc: *parser.Document, writer: anytype) !void {
try writer.writeAll("<!DOCTYPE html>\n");
try writeNode(parser.documentToNode(doc), writer);
try writeChildren(parser.documentToNode(doc), writer);
try writer.writeAll("\n");
}
pub fn writeNode(node: *parser.Node, writer: anytype) anyerror!void {
switch (try parser.nodeType(node)) {
.element => {
// open the tag
const tag = try parser.nodeLocalName(node);
try writer.writeAll("<");
try writer.writeAll(tag);
// write the attributes
const _map = try parser.nodeGetAttributes(node);
if (_map) |map| {
const ln = try parser.namedNodeMapGetLength(map);
for (0..ln) |i| {
const attr = try parser.namedNodeMapItem(map, @intCast(i)) orelse break;
try writer.writeAll(" ");
try writer.writeAll(try parser.attributeGetName(attr));
try writer.writeAll("=\"");
const attribute_value = try parser.attributeGetValue(attr) orelse "";
try writeEscapedAttributeValue(writer, attribute_value);
try writer.writeAll("\"");
}
}
try writer.writeAll(">");
// void elements can't have any content.
if (try isVoid(parser.nodeToElement(node))) return;
// write the children
// TODO avoid recursion
try writeChildren(node, writer);
// close the tag
try writer.writeAll("</");
try writer.writeAll(tag);
try writer.writeAll(">");
},
.text => {
const v = try parser.nodeValue(node) orelse return;
try writeEscapedTextNode(writer, v);
},
.cdata_section => {
const v = try parser.nodeValue(node) orelse return;
try writer.writeAll("<![CDATA[");
try writer.writeAll(v);
try writer.writeAll("]]>");
},
.comment => {
const v = try parser.nodeValue(node) orelse return;
try writer.writeAll("<!--");
try writer.writeAll(v);
try writer.writeAll("-->");
},
// TODO handle processing instruction dump
.processing_instruction => return,
// document fragment is outside of the main document DOM, so we
// don't output it.
.document_fragment => return,
// document will never be called, but required for completeness.
.document => return,
// done globally instead, but required for completeness.
.document_type => return,
// deprecated
.attribute => return,
.entity_reference => return,
.entity => return,
.notation => return,
}
}
// writer must be a std.io.Writer
pub fn writeNode(root: *parser.Node, writer: anytype) !void {
pub fn writeChildren(root: *parser.Node, writer: anytype) !void {
const walker = Walker{};
var next: ?*parser.Node = null;
while (true) {
next = try walker.get_next(root, next) orelse break;
switch (try parser.nodeType(next.?)) {
.element => {
// open the tag
const tag = try parser.nodeLocalName(next.?);
try writer.writeAll("<");
try writer.writeAll(tag);
// write the attributes
const map = try parser.nodeGetAttributes(next.?);
const ln = try parser.namedNodeMapGetLength(map);
var i: u32 = 0;
while (i < ln) {
const attr = try parser.namedNodeMapItem(map, i) orelse break;
try writer.writeAll(" ");
try writer.writeAll(try parser.attributeGetName(attr));
try writer.writeAll("=\"");
try writer.writeAll(try parser.attributeGetValue(attr) orelse "");
try writer.writeAll("\"");
i += 1;
}
try writer.writeAll(">");
// void elements can't have any content.
if (try isVoid(parser.nodeToElement(next.?))) continue;
// write the children
// TODO avoid recursion
try writeNode(next.?, writer);
// close the tag
try writer.writeAll("</");
try writer.writeAll(tag);
try writer.writeAll(">");
},
.text => {
const v = try parser.nodeValue(next.?) orelse continue;
try writer.writeAll(v);
},
.cdata_section => {
const v = try parser.nodeValue(next.?) orelse continue;
try writer.writeAll("<![CDATA[");
try writer.writeAll(v);
try writer.writeAll("]]>");
},
.comment => {
const v = try parser.nodeValue(next.?) orelse continue;
try writer.writeAll("<!--");
try writer.writeAll(v);
try writer.writeAll("-->");
},
// TODO handle processing instruction dump
.processing_instruction => continue,
// document fragment is outside of the main document DOM, so we
// don't output it.
.document_fragment => continue,
// document will never be called, but required for completeness.
.document => continue,
// done globally instead, but required for completeness.
.document_type => continue,
// deprecated
.attribute => continue,
.entity_reference => continue,
.entity => continue,
.notation => continue,
}
try writeNode(next.?, writer);
}
}
@@ -115,18 +119,87 @@ fn isVoid(elem: *parser.Element) !bool {
};
}
fn writeEscapedTextNode(writer: anytype, value: []const u8) !void {
var v = value;
while (v.len > 0) {
const index = std.mem.indexOfAnyPos(u8, v, 0, &.{ '&', '<', '>' }) orelse {
return writer.writeAll(v);
};
try writer.writeAll(v[0..index]);
switch (v[index]) {
'&' => try writer.writeAll("&amp;"),
'<' => try writer.writeAll("&lt;"),
'>' => try writer.writeAll("&gt;"),
else => unreachable,
}
v = v[index + 1 ..];
}
}
fn writeEscapedAttributeValue(writer: anytype, value: []const u8) !void {
var v = value;
while (v.len > 0) {
const index = std.mem.indexOfAnyPos(u8, v, 0, &.{ '&', '<', '>', '"' }) orelse {
return writer.writeAll(v);
};
try writer.writeAll(v[0..index]);
switch (v[index]) {
'&' => try writer.writeAll("&amp;"),
'<' => try writer.writeAll("&lt;"),
'>' => try writer.writeAll("&gt;"),
'"' => try writer.writeAll("&quot;"),
else => unreachable,
}
v = v[index + 1 ..];
}
}
const testing = std.testing;
test "dump.writeHTML" {
const out = try std.fs.openFileAbsolute("/dev/null", .{ .mode = .write_only });
defer out.close();
try testWriteHTML(
"<div id=\"content\">Over 9000!</div>",
"<div id=\"content\">Over 9000!</div>",
);
const file = try std.fs.cwd().openFile("test.html", .{});
defer file.close();
try testWriteHTML(
"<root><!-- a comment --></root>",
"<root><!-- a comment --></root>",
);
const doc_html = try parser.documentHTMLParse(file.reader(), "UTF-8");
// ignore close error
try testWriteHTML(
"<p>&lt; &gt; &amp;</p>",
"<p>&lt; &gt; &amp;</p>",
);
try testWriteHTML(
"<p id=\"&quot;&gt;&lt;&amp;&quot;''\">wat?</p>",
"<p id='\">&lt;&amp;&quot;&#39;&apos;'>wat?</p>",
);
try testWriteFullHTML(
\\<!DOCTYPE html>
\\<html><head><title>It's over what?</title><meta name="a" value="b">
\\</head><body>9000</body></html>
\\
, "<html><title>It's over what?</title><meta name=a value=\"b\">\n<body>9000");
}
fn testWriteHTML(comptime expected_body: []const u8, src: []const u8) !void {
const expected =
"<!DOCTYPE html>\n<html><head></head><body>" ++
expected_body ++
"</body></html>\n";
return testWriteFullHTML(expected, src);
}
fn testWriteFullHTML(comptime expected: []const u8, src: []const u8) !void {
var buf = std.ArrayListUnmanaged(u8){};
defer buf.deinit(testing.allocator);
const doc_html = try parser.documentHTMLParseFromStr(src);
defer parser.documentHTMLClose(doc_html) catch {};
const doc = parser.documentHTMLToDocument(doc_html);
try writeHTML(doc, out);
try writeHTML(doc, buf.writer(testing.allocator));
try testing.expectEqualStrings(expected, buf.items);
}

60
src/browser/env.zig Normal file
View File

@@ -0,0 +1,60 @@
const std = @import("std");
const parser = @import("netsurf.zig");
const URL = @import("../url.zig").URL;
const js = @import("../runtime/js.zig");
const storage = @import("storage/storage.zig");
const generate = @import("../runtime/generate.zig");
const Loop = @import("../runtime/loop.zig").Loop;
const HttpClient = @import("../http/client.zig").Client;
const Renderer = @import("browser.zig").Renderer;
const WebApis = struct {
// Wrapped like this for debug ergonomics.
// When we create our Env, a few lines down, we define it as:
// pub const Env = js.Env(*SessionState, WebApis);
//
// If there's a compile time error witht he Env, it's type will be readable,
// i.e.: runtime.js.Env(*browser.env.SessionState, browser.env.WebApis)
//
// But if we didn't wrap it in the struct, like we once didn't, and defined
// env as:
// pub const Env = js.Env(*SessionState, Interfaces);
//
// Because Interfaces is an anynoumous type, it doesn't have a friendly name
// and errors would be something like:
// runtime.js.Env(*browser.env.SessionState, .{...A HUNDRED TYPES...})
pub const Interfaces = generate.Tuple(.{
@import("crypto/crypto.zig").Crypto,
@import("console/console.zig").Console,
@import("dom/dom.zig").Interfaces,
@import("events/event.zig").Interfaces,
@import("html/html.zig").Interfaces,
@import("iterator/iterator.zig").Interfaces,
@import("storage/storage.zig").Interfaces,
@import("url/url.zig").Interfaces,
@import("xhr/xhr.zig").Interfaces,
@import("xmlserializer/xmlserializer.zig").Interfaces,
});
};
pub const JsThis = Env.JsThis;
pub const JsObject = Env.JsObject;
pub const Callback = Env.Callback;
pub const Env = js.Env(*SessionState, WebApis);
pub const Global = @import("html/window.zig").Window;
pub const SessionState = struct {
loop: *Loop,
url: *const URL,
renderer: *Renderer,
arena: std.mem.Allocator,
http_client: *HttpClient,
cookie_jar: *storage.CookieJar,
document: ?*parser.DocumentHTML,
// dangerous, but set by the JS framework
// shorter-lived than the arena above, which
// exists for the entire rendering of the page
call_arena: std.mem.Allocator = undefined,
};

View File

@@ -0,0 +1,79 @@
// 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");
const Event = @import("event.zig").Event;
const JsObject = @import("../env.zig").JsObject;
// https://dom.spec.whatwg.org/#interface-customevent
pub const CustomEvent = struct {
pub const prototype = *Event;
proto: parser.Event,
detail: ?JsObject,
const CustomEventInit = struct {
bubbles: bool = false,
cancelable: bool = false,
composed: bool = false,
detail: ?JsObject = null,
};
pub fn constructor(event_type: []const u8, opts_: ?CustomEventInit) !CustomEvent {
const opts = opts_ orelse CustomEventInit{};
const event = try parser.eventCreate();
defer parser.eventDestroy(event);
try parser.eventInit(event, event_type, .{
.bubbles = opts.bubbles,
.cancelable = opts.cancelable,
.composed = opts.composed,
});
return .{
.proto = event.*,
.detail = if (opts.detail) |d| try d.persist() else null,
};
}
pub fn get_detail(self: *CustomEvent) ?JsObject {
return self.detail;
}
};
const testing = @import("../../testing.zig");
test "Browser.CustomEvent" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let capture = null", "undefined" },
.{ "const el = document.createElement('div');", "undefined" },
.{ "el.addEventListener('c1', (e) => { capture = 'c1-' + new String(e.detail)})", "undefined" },
.{ "el.addEventListener('c2', (e) => { capture = 'c2-' + new String(e.detail.over)})", "undefined" },
.{ "el.dispatchEvent(new CustomEvent('c1'));", "true" },
.{ "capture", "c1-null" },
.{ "el.dispatchEvent(new CustomEvent('c1', {detail: '123'}));", "true" },
.{ "capture", "c1-123" },
.{ "el.dispatchEvent(new CustomEvent('c2', {detail: {over: 9000}}));", "true" },
.{ "capture", "c2-9000" },
}, .{});
}

View File

@@ -0,0 +1,270 @@
// 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 parser = @import("../netsurf.zig");
const Callback = @import("../env.zig").Callback;
const generate = @import("../../runtime/generate.zig");
const DOMException = @import("../dom/exceptions.zig").DOMException;
const EventTarget = @import("../dom/event_target.zig").EventTarget;
const EventTargetUnion = @import("../dom/event_target.zig").Union;
const CustomEvent = @import("custom_event.zig").CustomEvent;
const ProgressEvent = @import("../xhr/progress_event.zig").ProgressEvent;
const log = std.log.scoped(.events);
// Event interfaces
pub const Interfaces = .{
Event,
CustomEvent,
ProgressEvent,
};
pub const Union = generate.Union(Interfaces);
// https://dom.spec.whatwg.org/#event
pub const Event = struct {
pub const Self = parser.Event;
pub const Exception = DOMException;
pub const EventInit = parser.EventInit;
// JS
// --
pub const _CAPTURING_PHASE = 1;
pub const _AT_TARGET = 2;
pub const _BUBBLING_PHASE = 3;
pub fn toInterface(evt: *parser.Event) !Union {
return switch (try parser.eventGetInternalType(evt)) {
.event => .{ .Event = evt },
.custom_event => .{ .CustomEvent = @as(*CustomEvent, @ptrCast(evt)).* },
.progress_event => .{ .ProgressEvent = @as(*ProgressEvent, @ptrCast(evt)).* },
};
}
pub fn constructor(event_type: []const u8, opts: ?EventInit) !*parser.Event {
const event = try parser.eventCreate();
try parser.eventInit(event, event_type, opts orelse EventInit{});
return event;
}
// Getters
pub fn get_type(self: *parser.Event) ![]const u8 {
return try parser.eventType(self);
}
pub fn get_target(self: *parser.Event) !?EventTargetUnion {
const et = try parser.eventTarget(self);
if (et == null) return null;
return try EventTarget.toInterface(et.?);
}
pub fn get_currentTarget(self: *parser.Event) !?EventTargetUnion {
const et = try parser.eventCurrentTarget(self);
if (et == null) return null;
return try EventTarget.toInterface(et.?);
}
pub fn get_eventPhase(self: *parser.Event) !u8 {
return try parser.eventPhase(self);
}
pub fn get_bubbles(self: *parser.Event) !bool {
return try parser.eventBubbles(self);
}
pub fn get_cancelable(self: *parser.Event) !bool {
return try parser.eventCancelable(self);
}
pub fn get_defaultPrevented(self: *parser.Event) !bool {
return try parser.eventDefaultPrevented(self);
}
pub fn get_isTrusted(self: *parser.Event) !bool {
return try parser.eventIsTrusted(self);
}
pub fn get_timestamp(self: *parser.Event) !u32 {
return try parser.eventTimestamp(self);
}
// Methods
pub fn _initEvent(
self: *parser.Event,
eventType: []const u8,
bubbles: ?bool,
cancelable: ?bool,
) !void {
const opts = EventInit{
.bubbles = bubbles orelse false,
.cancelable = cancelable orelse false,
};
return try parser.eventInit(self, eventType, opts);
}
pub fn _stopPropagation(self: *parser.Event) !void {
return try parser.eventStopPropagation(self);
}
pub fn _stopImmediatePropagation(self: *parser.Event) !void {
return try parser.eventStopImmediatePropagation(self);
}
pub fn _preventDefault(self: *parser.Event) !void {
return try parser.eventPreventDefault(self);
}
};
pub const EventHandler = struct {
callback: Callback,
node: parser.EventNode,
pub fn init(allocator: Allocator, callback: Callback) !*EventHandler {
const eh = try allocator.create(EventHandler);
eh.* = .{
.callback = callback,
.node = .{
.id = callback.id,
.func = handle,
},
};
return eh;
}
fn handle(node: *parser.EventNode, event: *parser.Event) void {
const ievent = Event.toInterface(event) catch |err| {
log.err("Event.toInterface: {}", .{err});
return;
};
const self: *EventHandler = @fieldParentPtr("node", node);
var result: Callback.Result = undefined;
self.callback.tryCall(.{ievent}, &result) catch {
log.err("event handler error: {s}", .{result.exception});
log.debug("stack:\n{s}", .{result.stack orelse "???"});
};
}
};
const testing = @import("../../testing.zig");
test "Browser.Event" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let content = document.getElementById('content')", "undefined" },
.{ "let para = document.getElementById('para')", "undefined" },
.{ "var nb = 0; var evt", "undefined" },
}, .{});
try runner.testCases(&.{
.{
\\ content.addEventListener('target', function(e) {
\\ evt = e; nb = nb + 1;
\\ e.preventDefault();
\\ })
,
"undefined",
},
.{ "content.dispatchEvent(new Event('target', {bubbles: true, cancelable: true}))", "false" },
.{ "nb", "1" },
.{ "evt.target === content", "true" },
.{ "evt.bubbles", "true" },
.{ "evt.cancelable", "true" },
.{ "evt.defaultPrevented", "true" },
.{ "evt.isTrusted", "true" },
.{ "evt.timestamp > 1704063600", "true" }, // 2024/01/01 00:00
// event.type, event.currentTarget, event.phase checked in EventTarget
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{
\\ content.addEventListener('stop',function(e) {
\\ e.stopPropagation();
\\ nb = nb + 1;
\\ }, true)
,
"undefined",
},
// the following event listener will not be invoked
.{
\\ para.addEventListener('stop',function(e) {
\\ nb = nb + 1;
\\ })
,
"undefined",
},
.{ "para.dispatchEvent(new Event('stop'))", "true" },
.{ "nb", "1" }, // will be 2 if event was not stopped at content event listener
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{
\\ content.addEventListener('immediate', function(e) {
\\ e.stopImmediatePropagation();
\\ nb = nb + 1;
\\ })
,
"undefined",
},
// the following event listener will not be invoked
.{
\\ content.addEventListener('immediate', function(e) {
\\ nb = nb + 1;
\\ })
,
"undefined",
},
.{ "content.dispatchEvent(new Event('immediate'))", "true" },
.{ "nb", "1" }, // will be 2 if event was not stopped at first content event listener
}, .{});
try runner.testCases(&.{
.{ "nb = 0", "0" },
.{
\\ content.addEventListener('legacy', function(e) {
\\ evt = e; nb = nb + 1;
\\ })
,
"undefined",
},
.{ "let evtLegacy = document.createEvent('Event')", "undefined" },
.{ "evtLegacy.initEvent('legacy')", "undefined" },
.{ "content.dispatchEvent(evtLegacy)", "true" },
.{ "nb", "1" },
}, .{});
try runner.testCases(&.{
.{ "var nb = 0; var evt = null; function cbk(event) { nb ++; evt=event; }", "undefined" },
.{ "document.addEventListener('count', cbk)", "undefined" },
.{ "document.removeEventListener('count', cbk)", "undefined" },
.{ "document.dispatchEvent(new Event('count'))", "true" },
.{ "nb", "0" },
}, .{});
}

View File

@@ -18,25 +18,22 @@
const std = @import("std");
const parser = @import("netsurf");
const parser = @import("../netsurf.zig");
const SessionState = @import("../env.zig").SessionState;
const jsruntime = @import("jsruntime");
const Case = jsruntime.test_utils.Case;
const checkCases = jsruntime.test_utils.checkCases;
const Node = @import("../dom/node.zig").Node;
const Document = @import("../dom/document.zig").Document;
const NodeList = @import("../dom/nodelist.zig").NodeList;
const HTMLElem = @import("elements.zig");
const Location = @import("location.zig").Location;
const collection = @import("../dom/html_collection.zig");
const Walker = @import("../dom/walker.zig").WalkerDepthFirst;
const Cookie = @import("../storage/cookie.zig").Cookie;
// WEB IDL https://html.spec.whatwg.org/#the-document-object
pub const HTMLDocument = struct {
pub const Self = parser.DocumentHTML;
pub const prototype = *Document;
pub const mem_guarantied = true;
pub const subtype = .node;
// JS funcs
// --------
@@ -78,14 +75,19 @@ pub const HTMLDocument = struct {
}
}
// TODO: not implemented by libdom
pub fn get_cookie(_: *parser.DocumentHTML) ![]const u8 {
return error.NotImplemented;
pub fn get_cookie(_: *parser.DocumentHTML, state: *SessionState) ![]const u8 {
var buf: std.ArrayListUnmanaged(u8) = .{};
try state.cookie_jar.forRequest(&state.url.uri, buf.writer(state.arena), .{ .navigation = true });
return buf.items;
}
// TODO: not implemented by libdom
pub fn set_cookie(_: *parser.DocumentHTML, _: []const u8) ![]const u8 {
return error.NotImplemented;
pub fn set_cookie(_: *parser.DocumentHTML, cookie_str: []const u8, state: *SessionState) ![]const u8 {
// we use the cookie jar's allocator to parse the cookie because it
// outlives the page's arena.
const c = try Cookie.parse(state.cookie_jar.allocator, &state.url.uri, cookie_str);
errdefer c.deinit();
try state.cookie_jar.add(c, std.time.timestamp());
return cookie_str;
}
pub fn get_title(self: *parser.DocumentHTML) ![]const u8 {
@@ -97,44 +99,44 @@ pub const HTMLDocument = struct {
return v;
}
pub fn _getElementsByName(self: *parser.DocumentHTML, alloc: std.mem.Allocator, name: []const u8) !NodeList {
var list = NodeList.init();
errdefer list.deinit(alloc);
pub fn _getElementsByName(self: *parser.DocumentHTML, name: []const u8, state: *SessionState) !NodeList {
const arena = state.arena;
var list: NodeList = .{};
if (name.len == 0) return list;
const root = parser.documentHTMLToNode(self);
var c = try collection.HTMLCollectionByName(alloc, root, name, false);
var c = try collection.HTMLCollectionByName(arena, root, name, false);
const ln = try c.get_length();
var i: u32 = 0;
while (i < ln) {
const n = try c.item(i) orelse break;
try list.append(alloc, n);
try list.append(arena, n);
i += 1;
}
return list;
}
pub fn get_images(self: *parser.DocumentHTML, alloc: std.mem.Allocator) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(alloc, parser.documentHTMLToNode(self), "img", false);
pub fn get_images(self: *parser.DocumentHTML, state: *SessionState) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(state.arena, parser.documentHTMLToNode(self), "img", false);
}
pub fn get_embeds(self: *parser.DocumentHTML, alloc: std.mem.Allocator) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(alloc, parser.documentHTMLToNode(self), "embed", false);
pub fn get_embeds(self: *parser.DocumentHTML, state: *SessionState) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(state.arena, parser.documentHTMLToNode(self), "embed", false);
}
pub fn get_plugins(self: *parser.DocumentHTML, alloc: std.mem.Allocator) !collection.HTMLCollection {
return get_embeds(self, alloc);
pub fn get_plugins(self: *parser.DocumentHTML, state: *SessionState) !collection.HTMLCollection {
return get_embeds(self, state);
}
pub fn get_forms(self: *parser.DocumentHTML, alloc: std.mem.Allocator) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(alloc, parser.documentHTMLToNode(self), "form", false);
pub fn get_forms(self: *parser.DocumentHTML, state: *SessionState) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(state.arena, parser.documentHTMLToNode(self), "form", false);
}
pub fn get_scripts(self: *parser.DocumentHTML, alloc: std.mem.Allocator) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(alloc, parser.documentHTMLToNode(self), "script", false);
pub fn get_scripts(self: *parser.DocumentHTML, state: *SessionState) !collection.HTMLCollection {
return try collection.HTMLCollectionByTagName(state.arena, parser.documentHTMLToNode(self), "script", false);
}
pub fn get_applets(_: *parser.DocumentHTML) !collection.HTMLCollection {
@@ -153,8 +155,12 @@ pub const HTMLDocument = struct {
return try collection.HTMLCollectionAll(parser.documentHTMLToNode(self), true);
}
pub fn get_currentScript(_: *parser.DocumentHTML) !?*parser.Element {
return null;
pub fn get_currentScript(self: *parser.DocumentHTML) !?*parser.Script {
return try parser.documentHTMLGetCurrentScript(self);
}
pub fn get_location(self: *parser.DocumentHTML) !?*Location {
return try parser.documentHTMLGetLocation(Location, self);
}
pub fn get_designMode(_: *parser.DocumentHTML) []const u8 {
@@ -201,54 +207,57 @@ pub const HTMLDocument = struct {
pub fn set_bgColor(_: *parser.DocumentHTML, _: []const u8) []const u8 {
return "";
}
pub fn deinit(_: *parser.DocumentHTML, _: std.mem.Allocator) void {}
};
// Tests
// -----
pub fn testExecFn(
_: std.mem.Allocator,
js_env: *jsruntime.Env,
) anyerror!void {
var constructor = [_]Case{
.{ .src = "document.__proto__.constructor.name", .ex = "HTMLDocument" },
.{ .src = "document.__proto__.__proto__.constructor.name", .ex = "Document" },
.{ .src = "document.body.localName == 'body'", .ex = "true" },
};
try checkCases(js_env, &constructor);
const testing = @import("../../testing.zig");
var getters = [_]Case{
.{ .src = "document.domain", .ex = "" },
.{ .src = "document.referrer", .ex = "" },
.{ .src = "document.title", .ex = "" },
.{ .src = "document.body.localName", .ex = "body" },
.{ .src = "document.head.localName", .ex = "head" },
.{ .src = "document.images.length", .ex = "0" },
.{ .src = "document.embeds.length", .ex = "0" },
.{ .src = "document.plugins.length", .ex = "0" },
.{ .src = "document.scripts.length", .ex = "0" },
.{ .src = "document.forms.length", .ex = "0" },
.{ .src = "document.links.length", .ex = "1" },
.{ .src = "document.applets.length", .ex = "0" },
.{ .src = "document.anchors.length", .ex = "0" },
.{ .src = "document.all.length", .ex = "8" },
.{ .src = "document.currentScript", .ex = "null" },
};
try checkCases(js_env, &getters);
test "Browser.HTML.Document" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
var titles = [_]Case{
.{ .src = "document.title = 'foo'", .ex = "foo" },
.{ .src = "document.title", .ex = "foo" },
.{ .src = "document.title = ''", .ex = "" },
};
try checkCases(js_env, &titles);
try runner.testCases(&.{
.{ "document.__proto__.constructor.name", "HTMLDocument" },
.{ "document.__proto__.__proto__.constructor.name", "Document" },
.{ "document.body.localName == 'body'", "true" },
}, .{});
var getElementsByName = [_]Case{
.{ .src = "document.getElementById('link').setAttribute('name', 'foo')", .ex = "undefined" },
.{ .src = "let list = document.getElementsByName('foo')", .ex = "undefined" },
.{ .src = "list.length", .ex = "1" },
};
try checkCases(js_env, &getElementsByName);
try runner.testCases(&.{
.{ "document.domain", "" },
.{ "document.referrer", "" },
.{ "document.title", "" },
.{ "document.body.localName", "body" },
.{ "document.head.localName", "head" },
.{ "document.images.length", "0" },
.{ "document.embeds.length", "0" },
.{ "document.plugins.length", "0" },
.{ "document.scripts.length", "0" },
.{ "document.forms.length", "0" },
.{ "document.links.length", "1" },
.{ "document.applets.length", "0" },
.{ "document.anchors.length", "0" },
.{ "document.all.length", "8" },
.{ "document.currentScript", "null" },
}, .{});
try runner.testCases(&.{
.{ "document.title = 'foo'", "foo" },
.{ "document.title", "foo" },
.{ "document.title = ''", "" },
}, .{});
try runner.testCases(&.{
.{ "document.getElementById('link').setAttribute('name', 'foo')", "undefined" },
.{ "let list = document.getElementsByName('foo')", "undefined" },
.{ "list.length", "1" },
}, .{});
try runner.testCases(&.{
.{ "document.cookie", "" },
.{ "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" },
}, .{});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,120 @@
// 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");
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-history-interface
pub const History = struct {
const ScrollRestorationMode = enum {
auto,
manual,
};
scrollRestoration: ScrollRestorationMode = .auto,
state: std.json.Value = .null,
// count tracks the history length until we implement correctly pushstate.
count: u32 = 0,
pub fn get_length(self: *History) u32 {
// TODO return the real history length value.
return self.count;
}
pub fn get_scrollRestoration(self: *History) []const u8 {
return switch (self.scrollRestoration) {
.auto => "auto",
.manual => "manual",
};
}
pub fn set_scrollRestoration(self: *History, mode: []const u8) void {
if (std.mem.eql(u8, "manual", mode)) self.scrollRestoration = .manual;
if (std.mem.eql(u8, "auto", mode)) self.scrollRestoration = .auto;
}
pub fn get_state(self: *History) std.json.Value {
return self.state;
}
// TODO implement the function
// data must handle any argument. We could expect a std.json.Value but
// https://github.com/lightpanda-io/zig-js-runtime/issues/267 is missing.
pub fn _pushState(self: *History, data: []const u8, _: ?[]const u8, url: ?[]const u8) void {
self.count += 1;
_ = url;
_ = data;
}
// TODO implement the function
// data must handle any argument. We could expect a std.json.Value but
// https://github.com/lightpanda-io/zig-js-runtime/issues/267 is missing.
pub fn _replaceState(self: *History, data: []const u8, _: ?[]const u8, url: ?[]const u8) void {
_ = self;
_ = url;
_ = data;
}
// TODO implement the function
pub fn _go(self: *History, delta: ?i32) void {
_ = self;
_ = delta;
}
// TODO implement the function
pub fn _back(self: *History) void {
_ = self;
}
// TODO implement the function
pub fn _forward(self: *History) void {
_ = self;
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.HTML.History" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "history.scrollRestoration", "auto" },
.{ "history.scrollRestoration = 'manual'", "manual" },
.{ "history.scrollRestoration = 'foo'", "foo" },
.{ "history.scrollRestoration", "manual" },
.{ "history.scrollRestoration = 'auto'", "auto" },
.{ "history.scrollRestoration", "auto" },
.{ "history.state", "null" },
.{ "history.pushState({}, null, '')", "undefined" },
.{ "history.replaceState({}, null, '')", "undefined" },
.{ "history.go()", "undefined" },
.{ "history.go(1)", "undefined" },
.{ "history.go(-1)", "undefined" },
.{ "history.forward()", "undefined" },
.{ "history.back()", "undefined" },
}, .{});
}

View File

@@ -16,16 +16,22 @@
// 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 generate = @import("../generate.zig");
const HTMLDocument = @import("document.zig").HTMLDocument;
const HTMLElem = @import("elements.zig");
const SVGElem = @import("svg_elements.zig");
const Window = @import("window.zig").Window;
const Navigator = @import("navigator.zig").Navigator;
const History = @import("history.zig").History;
const Location = @import("location.zig").Location;
pub const Interfaces = generate.Tuple(.{
pub const Interfaces = .{
HTMLDocument,
HTMLElem.HTMLElement,
HTMLElem.HTMLMediaElement,
HTMLElem.Interfaces,
SVGElem.SVGElement,
Window,
});
Navigator,
History,
Location,
};

View File

@@ -0,0 +1,107 @@
// 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 SessionState = @import("../env.zig").SessionState;
const URL = @import("../url/url.zig").URL;
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-location-interface
pub const Location = struct {
url: ?URL = null,
pub fn get_href(self: *Location, state: *SessionState) ![]const u8 {
if (self.url) |*u| return u.get_href(state);
return "";
}
pub fn get_protocol(self: *Location, state: *SessionState) ![]const u8 {
if (self.url) |*u| return u.get_protocol(state);
return "";
}
pub fn get_host(self: *Location, state: *SessionState) ![]const u8 {
if (self.url) |*u| return u.get_host(state);
return "";
}
pub fn get_hostname(self: *Location) []const u8 {
if (self.url) |*u| return u.get_hostname();
return "";
}
pub fn get_port(self: *Location, state: *SessionState) ![]const u8 {
if (self.url) |*u| return u.get_port(state);
return "";
}
pub fn get_pathname(self: *Location) []const u8 {
if (self.url) |*u| return u.get_pathname();
return "";
}
pub fn get_search(self: *Location, state: *SessionState) ![]const u8 {
if (self.url) |*u| return u.get_search(state);
return "";
}
pub fn get_hash(self: *Location, state: *SessionState) ![]const u8 {
if (self.url) |*u| return u.get_hash(state);
return "";
}
pub fn get_origin(self: *Location, state: *SessionState) ![]const u8 {
if (self.url) |*u| return u.get_origin(state);
return "";
}
// TODO
pub fn _assign(_: *Location, url: []const u8) !void {
_ = url;
}
// TODO
pub fn _replace(_: *Location, url: []const u8) !void {
_ = url;
}
// TODO
pub fn _reload(_: *Location) !void {}
pub fn _toString(self: *Location, state: *SessionState) ![]const u8 {
return try self.get_href(state);
}
};
const testing = @import("../../testing.zig");
test "Browser.HTML.Location" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "location.href", "https://lightpanda.io/opensource-browser/" },
.{ "document.location.href", "https://lightpanda.io/opensource-browser/" },
.{ "location.host", "lightpanda.io" },
.{ "location.hostname", "lightpanda.io" },
.{ "location.origin", "https://lightpanda.io" },
.{ "location.pathname", "/opensource-browser/" },
.{ "location.hash", "" },
.{ "location.port", "" },
.{ "location.search", "" },
}, .{});
}

View File

@@ -0,0 +1,96 @@
// 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 builtin = @import("builtin");
// https://html.spec.whatwg.org/multipage/system-state.html#navigator
pub const Navigator = struct {
agent: []const u8 = "Lightpanda/1.0",
version: []const u8 = "1.0",
vendor: []const u8 = "",
platform: []const u8 = std.fmt.comptimePrint("{any} {any}", .{ builtin.os.tag, builtin.cpu.arch }),
language: []const u8 = "en-US",
pub fn get_userAgent(self: *Navigator) []const u8 {
return self.agent;
}
pub fn get_appCodeName(_: *Navigator) []const u8 {
return "Mozilla";
}
pub fn get_appName(_: *Navigator) []const u8 {
return "Netscape";
}
pub fn get_appVersion(self: *Navigator) []const u8 {
return self.version;
}
pub fn get_platform(self: *Navigator) []const u8 {
return self.platform;
}
pub fn get_product(_: *Navigator) []const u8 {
return "Gecko";
}
pub fn get_productSub(_: *Navigator) []const u8 {
return "20030107";
}
pub fn get_vendor(self: *Navigator) []const u8 {
return self.vendor;
}
pub fn get_vendorSub(_: *Navigator) []const u8 {
return "";
}
pub fn get_language(self: *Navigator) []const u8 {
return self.language;
}
// TODO wait for arrays.
//pub fn get_languages(self: *Navigator) [][]const u8 {
// return .{self.language};
//}
pub fn get_online(_: *Navigator) bool {
return true;
}
pub fn _registerProtocolHandler(_: *Navigator, scheme: []const u8, url: []const u8) void {
_ = scheme;
_ = url;
}
pub fn _unregisterProtocolHandler(_: *Navigator, scheme: []const u8, url: []const u8) void {
_ = scheme;
_ = url;
}
pub fn get_cookieEnabled(_: *Navigator) bool {
return true;
}
};
// Tests
// -----
const testing = @import("../../testing.zig");
test "Browser.HTML.Navigator" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "navigator.userAgent", "Lightpanda/1.0" },
.{ "navigator.appVersion", "1.0" },
.{ "navigator.language", "en-US" },
}, .{});
}

View File

@@ -0,0 +1,41 @@
// 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 Element = @import("../dom/element.zig").Element;
// Support for SVGElements is very limited, this is a dummy implementation.
// This is here no to be able to support `element instanceof SVGElement;` in JavaScript.
// https://developer.mozilla.org/en-US/docs/Web/API/SVGElement
pub const SVGElement = struct {
// Currently the prototype chain is not implemented (will not be returned by toInterface())
// For that we need parser.SvgElement and the derived types with tags in the v-table.
pub const prototype = *Element;
// While this is a Node, could consider not exposing the subtype untill we have
// a Self type to cast to.
pub const subtype = .node;
};
const testing = @import("../../testing.zig");
test "Browser.HTML.SVGElement" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "'AString' instanceof SVGElement", "false" },
}, .{});
}

223
src/browser/html/window.zig Normal file
View File

@@ -0,0 +1,223 @@
// 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 Callback = @import("../env.zig").Callback;
const SessionState = @import("../env.zig").SessionState;
const Loop = @import("../../runtime/loop.zig").Loop;
const Navigator = @import("navigator.zig").Navigator;
const History = @import("history.zig").History;
const Location = @import("location.zig").Location;
const Crypto = @import("../crypto/crypto.zig").Crypto;
const Console = @import("../console/console.zig").Console;
const EventTarget = @import("../dom/event_target.zig").EventTarget;
const storage = @import("../storage/storage.zig");
const log = std.log.scoped(.window);
// https://dom.spec.whatwg.org/#interface-window-extensions
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#window
pub const Window = struct {
pub const prototype = *EventTarget;
// Extend libdom event target for pure zig struct.
base: parser.EventTargetTBase = parser.EventTargetTBase{},
document: ?*parser.DocumentHTML = null,
target: []const u8 = "",
history: History = .{},
location: Location = .{},
storage_shelf: ?*storage.Shelf = null,
// counter for having unique timer ids
timer_id: u31 = 0,
timers: std.AutoHashMapUnmanaged(u32, *TimerCallback) = .{},
crypto: Crypto = .{},
console: Console = .{},
navigator: Navigator = .{},
pub fn create(target: ?[]const u8, navigator: ?Navigator) Window {
return .{
.target = target orelse "",
.navigator = navigator orelse .{},
};
}
pub fn replaceLocation(self: *Window, loc: Location) !void {
self.location = loc;
if (self.document) |doc| {
try parser.documentHTMLSetLocation(Location, doc, &self.location);
}
}
pub fn replaceDocument(self: *Window, doc: *parser.DocumentHTML) !void {
self.document = doc;
try parser.documentHTMLSetLocation(Location, doc, &self.location);
}
pub fn setStorageShelf(self: *Window, shelf: *storage.Shelf) void {
self.storage_shelf = shelf;
}
pub fn get_window(self: *Window) *Window {
return self;
}
pub fn get_navigator(self: *Window) *Navigator {
return &self.navigator;
}
pub fn get_location(self: *Window) *Location {
return &self.location;
}
pub fn get_console(self: *Window) *Console {
return &self.console;
}
pub fn get_crypto(self: *Window) *Crypto {
return &self.crypto;
}
pub fn get_self(self: *Window) *Window {
return self;
}
pub fn get_parent(self: *Window) *Window {
return self;
}
pub fn get_document(self: *Window) ?*parser.DocumentHTML {
return self.document;
}
pub fn get_history(self: *Window) *History {
return &self.history;
}
pub fn get_name(self: *Window) []const u8 {
return self.target;
}
pub fn get_localStorage(self: *Window) !*storage.Bottle {
if (self.storage_shelf == null) return parser.DOMError.NotSupported;
return &self.storage_shelf.?.bucket.local;
}
pub fn get_sessionStorage(self: *Window) !*storage.Bottle {
if (self.storage_shelf == null) return parser.DOMError.NotSupported;
return &self.storage_shelf.?.bucket.session;
}
// TODO handle callback arguments.
pub fn _setTimeout(self: *Window, cbk: Callback, delay: ?u32, state: *SessionState) !u32 {
return self.createTimeout(cbk, delay, state, false);
}
// TODO handle callback arguments.
pub fn _setInterval(self: *Window, cbk: Callback, delay: ?u32, state: *SessionState) !u32 {
return self.createTimeout(cbk, delay, state, true);
}
pub fn _clearTimeout(self: *Window, id: u32, state: *SessionState) !void {
const kv = self.timers.fetchRemove(id) orelse return;
try state.loop.cancel(kv.value.loop_id);
}
pub fn _clearInterval(self: *Window, id: u32, state: *SessionState) !void {
const kv = self.timers.fetchRemove(id) orelse return;
try state.loop.cancel(kv.value.loop_id);
}
pub fn createTimeout(self: *Window, cbk: Callback, delay_: ?u32, state: *SessionState, comptime repeat: bool) !u32 {
if (self.timers.count() > 512) {
return error.TooManyTimeout;
}
const timer_id = self.timer_id +% 1;
self.timer_id = timer_id;
const arena = state.arena;
const gop = try self.timers.getOrPut(arena, timer_id);
if (gop.found_existing) {
// this can only happen if we've created 2^31 timeouts.
return error.TooManyTimeout;
}
errdefer _ = self.timers.remove(timer_id);
const delay: u63 = @as(u63, (delay_ orelse 0)) * std.time.ns_per_ms;
const callback = try arena.create(TimerCallback);
callback.* = .{
.cbk = cbk,
.loop_id = 0, // we're going to set this to a real value shortly
.window = self,
.timer_id = timer_id,
.node = .{ .func = TimerCallback.run },
.repeat = if (repeat) delay else null,
};
callback.loop_id = try state.loop.timeout(delay, &callback.node);
gop.value_ptr.* = callback;
return timer_id;
}
};
const TimerCallback = struct {
// the internal loop id, need it when cancelling
loop_id: usize,
// the id of our timer (windows.timers key)
timer_id: u31,
// The JavaScript callback to execute
cbk: Callback,
// This is the internal data that the event loop tracks. We'll get this
// back in run and, from it, can get our TimerCallback instance
node: Loop.CallbackNode = undefined,
// if the event should be repeated
repeat: ?u63 = null,
window: *Window,
fn run(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
const self: *TimerCallback = @fieldParentPtr("node", node);
var result: Callback.Result = undefined;
self.cbk.tryCall(.{}, &result) catch {
log.err("timeout callback error: {s}", .{result.exception});
log.debug("stack:\n{s}", .{result.stack orelse "???"});
};
if (self.repeat) |r| {
// setInterval
repeat_delay.* = r;
return;
}
// setTimeout
_ = self.window.timers.remove(self.timer_id);
}
};

View File

@@ -0,0 +1,226 @@
pub const Interfaces = .{
U32Iterator,
};
pub const U32Iterator = struct {
length: u32,
index: u32 = 0,
pub const Return = struct {
value: u32,
done: bool,
};
pub fn _next(self: *U32Iterator) Return {
const i = self.index;
if (i >= self.length) {
return .{
.value = 0,
.done = true,
};
}
self.index = i + 1;
return .{
.value = i,
.done = false,
};
}
// Iterators should be iterable. There's a [JS] example on MDN that
// suggests this is the correct approach:
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterator_protocol
pub fn _symbol_iterator(self: *U32Iterator) *U32Iterator {
return self;
}
};
// A wrapper around an iterator that emits an Iterable result
// An iterable has a next() which emits a {done: bool, value: T} result
pub fn Iterable(comptime T: type, comptime JsName: []const u8) type {
// The inner iterator's return type.
// Maybe an error union.
// Definitely an optional
const RawValue = @typeInfo(@TypeOf(T._next)).@"fn".return_type.?;
const CanError = @typeInfo(RawValue) == .error_union;
const Value = blk: {
// Unwrap the RawValue
var V = RawValue;
if (CanError) {
V = @typeInfo(V).error_union.payload;
}
break :blk @typeInfo(V).optional.child;
};
const Result = struct {
done: bool,
// todo, technically, we should return undefined when done = true
// or even omit the value;
value: ?Value,
};
const ReturnType = if (CanError) T.Error!Result else Result;
return struct {
// the inner value iterator
inner: T,
// Generics don't generate clean names. Can't just take the resulting
// type name and use that as a the JS class name. So we always ask for
// an explicit JS class name
pub const js_name = JsName;
const Self = @This();
pub fn init(inner: T) Self {
return .{ .inner = inner };
}
pub fn _next(self: *Self) ReturnType {
const value = if (comptime CanError) try self.inner._next() else self.inner._next();
return .{ .done = value == null, .value = value };
}
pub fn _symbol_iterator(self: *Self) *Self {
return self;
}
};
}
// A wrapper around an iterator that emits integer/index keyed entries.
pub fn NumericEntries(comptime T: type, comptime JsName: []const u8) type {
// The inner iterator's return type.
// Maybe an error union.
// Definitely an optional
const RawValue = @typeInfo(@TypeOf(T._next)).@"fn".return_type.?;
const CanError = @typeInfo(RawValue) == .error_union;
const Value = blk: {
// Unwrap the RawValue
var V = RawValue;
if (CanError) {
V = @typeInfo(V).error_union.payload;
}
break :blk @typeInfo(V).optional.child;
};
const ReturnType = if (CanError) T.Error!?struct { u32, Value } else ?struct { u32, Value };
// Avoid ambiguity. We want to expose a NumericEntries(T).Iterable, so we
// need a declartion inside here for an "Iterable", but that will conflict
// with the above Iterable generic function we have.
const BaseIterable = Iterable;
return struct {
// the inner value iterator
inner: T,
index: u32,
const Self = @This();
// Generics don't generate clean names. Can't just take the resulting
// type name and use that as a the JS class name. So we always ask for
// an explicit JS class name
pub const js_name = JsName;
// re-exposed for when/if we compose this type into an Iterable
pub const Error = T.Error;
// This iterator as an iterable
pub const Iterable = BaseIterable(Self, JsName ++ "Iterable");
pub fn init(inner: T) Self {
return .{ .inner = inner, .index = 0 };
}
pub fn _next(self: *Self) ReturnType {
const value_ = if (comptime CanError) try self.inner._next() else self.inner._next();
const value = value_ orelse return null;
const index = self.index;
self.index = index + 1;
return .{ index, value };
}
// make the iterator, iterable
pub fn _symbol_iterator(self: *Self) Self.Iterable {
return Self.Iterable.init(self.*);
}
};
}
const testing = @import("../../testing.zig");
test "U32Iterator" {
{
var it = U32Iterator{ .length = 0 };
try testing.expectEqual(.{ .value = 0, .done = true }, it._next());
try testing.expectEqual(.{ .value = 0, .done = true }, it._next());
}
{
var it = U32Iterator{ .length = 3 };
try testing.expectEqual(.{ .value = 0, .done = false }, it._next());
try testing.expectEqual(.{ .value = 1, .done = false }, it._next());
try testing.expectEqual(.{ .value = 2, .done = false }, it._next());
try testing.expectEqual(.{ .value = 0, .done = true }, it._next());
try testing.expectEqual(.{ .value = 0, .done = true }, it._next());
}
}
test "NumericEntries" {
const it = DummyIterator{};
var entries = NumericEntries(DummyIterator, "DummyIterator").init(it);
const v1 = entries._next().?;
try testing.expectEqual(0, v1.@"0");
try testing.expectEqual("it's", v1.@"1");
const v2 = entries._next().?;
try testing.expectEqual(1, v2.@"0");
try testing.expectEqual("over", v2.@"1");
const v3 = entries._next().?;
try testing.expectEqual(2, v3.@"0");
try testing.expectEqual("9000!!", v3.@"1");
try testing.expectEqual(null, entries._next());
try testing.expectEqual(null, entries._next());
try testing.expectEqual(null, entries._next());
}
test "Iterable" {
const it = DummyIterator{};
var entries = Iterable(DummyIterator, "DummyIterator").init(it);
const v1 = entries._next();
try testing.expectEqual(false, v1.done);
try testing.expectEqual("it's", v1.value.?);
const v2 = entries._next();
try testing.expectEqual(false, v2.done);
try testing.expectEqual("over", v2.value.?);
const v3 = entries._next();
try testing.expectEqual(false, v3.done);
try testing.expectEqual("9000!!", v3.value.?);
try testing.expectEqual(true, entries._next().done);
try testing.expectEqual(true, entries._next().done);
try testing.expectEqual(true, entries._next().done);
}
const DummyIterator = struct {
index: u32 = 0,
pub fn _next(self: *DummyIterator) ?[]const u8 {
const index = self.index;
self.index = index + 1;
return switch (index) {
0 => "it's",
1 => "over",
2 => "9000!!",
else => null,
};
}
};

View File

@@ -1,91 +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 Client = @import("../http/Client.zig");
const user_agent = "Lightpanda.io/1.0";
pub const Loader = struct {
client: Client,
// use 64KB for headers buffer size.
server_header_buffer: [1024 * 64]u8 = undefined,
pub const Response = struct {
alloc: std.mem.Allocator,
req: *Client.Request,
pub fn deinit(self: *Response) void {
self.req.deinit();
self.alloc.destroy(self.req);
}
};
pub fn init(alloc: std.mem.Allocator) Loader {
return Loader{
.client = Client{
.allocator = alloc,
},
};
}
pub fn deinit(self: *Loader) void {
self.client.deinit();
}
// see
// https://ziglang.org/documentation/master/std/#A;std:http.Client.fetch
// for reference.
// The caller is responsible for calling `deinit()` on the `Response`.
pub fn get(self: *Loader, alloc: std.mem.Allocator, uri: std.Uri) !Response {
var resp = Response{
.alloc = alloc,
.req = try alloc.create(Client.Request),
};
errdefer alloc.destroy(resp.req);
resp.req.* = try self.client.open(.GET, uri, .{
.headers = .{
.user_agent = .{ .override = user_agent },
},
.extra_headers = &.{
.{ .name = "Accept", .value = "*/*" },
.{ .name = "Accept-Language", .value = "en-US,en;q=0.5" },
},
.server_header_buffer = &self.server_header_buffer,
});
errdefer resp.req.deinit();
try resp.req.send();
try resp.req.finish();
try resp.req.wait();
return resp;
}
};
test "basic url get" {
const alloc = std.testing.allocator;
var loader = Loader.init(alloc);
defer loader.deinit();
var result = try loader.get(alloc, "https://en.wikipedia.org/wiki/Main_Page");
defer result.deinit();
try std.testing.expect(result.req.response.status == std.http.Status.ok);
}

View File

@@ -21,7 +21,6 @@
// We replace the libdom default usage of allocations with mimalloc heap
// allocation to be able to free all memory used at once, like an arena usage.
const std = @import("std");
const c = @cImport({
@cInclude("mimalloc.h");
});

View File

@@ -17,141 +17,491 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const testing = std.testing;
const Allocator = std.mem.Allocator;
const strparser = @import("../str/parser.zig");
const Reader = strparser.Reader;
const trim = strparser.trim;
pub const Mime = struct {
content_type: ContentType,
params: []const u8 = "",
charset: ?[]const u8 = null,
const Self = @This();
pub const unknown = Mime{
.params = "",
.charset = "",
.content_type = .{ .unknown = {} },
};
const MimeError = error{
Empty,
TooBig,
Invalid,
InvalidChar,
pub const ContentTypeEnum = enum {
text_xml,
text_html,
text_javascript,
text_plain,
unknown,
other,
};
pub const ContentType = union(ContentTypeEnum) {
text_xml: void,
text_html: void,
text_javascript: void,
text_plain: void,
unknown: void,
other: struct { type: []const u8, sub_type: []const u8 },
};
pub fn parse(arena: Allocator, input: []u8) !Mime {
if (input.len > 255) {
return error.TooBig;
}
// Zig's trim API is broken. The return type is always `[]const u8`,
// even if the input type is `[]u8`. @constCast is safe here.
var normalized = @constCast(std.mem.trim(u8, input, &std.ascii.whitespace));
_ = std.ascii.lowerString(normalized, normalized);
const content_type, const type_len = try parseContentType(normalized);
if (type_len >= normalized.len) {
return .{ .content_type = content_type };
}
const params = trimLeft(normalized[type_len..]);
var charset: ?[]const u8 = null;
var it = std.mem.splitScalar(u8, params, ';');
while (it.next()) |attr| {
const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse return error.Invalid;
const name = trimLeft(attr[0..i]);
const value = trimRight(attr[i + 1 ..]);
if (value.len == 0) {
return error.Invalid;
}
const attribute_name = std.meta.stringToEnum(enum {
charset,
}, name) orelse continue;
switch (attribute_name) {
.charset => charset = try parseAttributeValue(arena, value),
}
}
return .{
.params = params,
.charset = charset,
.content_type = content_type,
};
}
pub fn sniff(body: []const u8) ?Mime {
// 0x0C is form feed
const content = std.mem.trimLeft(u8, body, &.{ ' ', '\t', '\n', '\r', 0x0C });
if (content.len == 0) {
return null;
}
if (content[0] != '<') {
if (std.mem.startsWith(u8, content, &.{ 0xEF, 0xBB, 0xBF })) {
// UTF-8 BOM
return .{ .content_type = .{ .text_plain = {} } };
}
if (std.mem.startsWith(u8, content, &.{ 0xFE, 0xFF })) {
// UTF-16 big-endian BOM
return .{ .content_type = .{ .text_plain = {} } };
}
if (std.mem.startsWith(u8, content, &.{ 0xFF, 0xFE })) {
// UTF-16 little-endian BOM
return .{ .content_type = .{ .text_plain = {} } };
}
return null;
}
// The longest prefix we have is "<!DOCTYPE HTML ", 15 bytes. If we're
// here, we already know content[0] == '<', so we can skip that. So 14
// bytes.
// +1 because we don't need the leading '<'
var buf: [14]u8 = undefined;
const stripped = content[1..];
const prefix_len = @min(stripped.len, buf.len);
const prefix = std.ascii.lowerString(&buf, stripped[0..prefix_len]);
// we already know it starts with a <
const known_prefixes = [_]struct { []const u8, ContentType }{
.{ "!doctype html", .{ .text_html = {} } },
.{ "html", .{ .text_html = {} } },
.{ "script", .{ .text_html = {} } },
.{ "iframe", .{ .text_html = {} } },
.{ "h1", .{ .text_html = {} } },
.{ "div", .{ .text_html = {} } },
.{ "font", .{ .text_html = {} } },
.{ "table", .{ .text_html = {} } },
.{ "a", .{ .text_html = {} } },
.{ "style", .{ .text_html = {} } },
.{ "title", .{ .text_html = {} } },
.{ "b", .{ .text_html = {} } },
.{ "body", .{ .text_html = {} } },
.{ "br", .{ .text_html = {} } },
.{ "p", .{ .text_html = {} } },
.{ "!--", .{ .text_html = {} } },
.{ "xml", .{ .text_xml = {} } },
};
inline for (known_prefixes) |kp| {
const known_prefix = kp.@"0";
if (std.mem.startsWith(u8, prefix, known_prefix) and prefix.len > known_prefix.len) {
const next = prefix[known_prefix.len];
// a "tag-terminating-byte"
if (next == ' ' or next == '>') {
return .{ .content_type = kp.@"1" };
}
}
}
return null;
}
pub fn isHTML(self: *const Mime) bool {
return self.content_type == .text_html;
}
// we expect value to be lowercase
fn parseContentType(value: []const u8) !struct { ContentType, usize } {
const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len;
const type_name = trimRight(value[0..end]);
const attribute_start = end + 1;
if (std.meta.stringToEnum(enum {
@"text/xml",
@"text/html",
@"text/javascript",
@"application/javascript",
@"application/x-javascript",
@"text/plain",
}, 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 = {} },
};
return .{ ct, attribute_start };
}
const separator = std.mem.indexOfScalarPos(u8, type_name, 0, '/') orelse return error.Invalid;
const main_type = value[0..separator];
const sub_type = trimRight(value[separator + 1 .. end]);
if (main_type.len == 0 or validType(main_type) == false) {
return error.Invalid;
}
if (sub_type.len == 0 or validType(sub_type) == false) {
return error.Invalid;
}
return .{ .{ .other = .{
.type = main_type,
.sub_type = sub_type,
} }, attribute_start };
}
const T_SPECIAL = blk: {
var v = [_]bool{false} ** 256;
for ("()<>@,;:\\\"/[]?=") |b| {
v[b] = true;
}
break :blk v;
};
fn parseAttributeValue(arena: Allocator, value: []const u8) ![]const u8 {
if (value[0] != '"') {
return value;
}
// 1 to skip the opening quote
var value_pos: usize = 1;
var unescaped_len: usize = 0;
const last = value.len - 1;
while (value_pos < value.len) {
switch (value[value_pos]) {
'"' => break,
'\\' => {
if (value_pos == last) {
return error.Invalid;
}
const next = value[value_pos + 1];
if (T_SPECIAL[next] == false) {
return error.Invalid;
}
value_pos += 2;
},
else => value_pos += 1,
}
unescaped_len += 1;
}
if (unescaped_len == 0) {
return error.Invalid;
}
value_pos = 1;
const owned = try arena.alloc(u8, unescaped_len);
for (0..unescaped_len) |i| {
switch (value[value_pos]) {
'"' => break,
'\\' => {
owned[i] = value[value_pos + 1];
value_pos += 2;
},
else => |c| {
owned[i] = c;
value_pos += 1;
},
}
}
return owned;
}
const VALID_CODEPOINTS = blk: {
var v: [256]bool = undefined;
for (0..256) |i| {
v[i] = std.ascii.isAlphanumeric(i);
}
for ("!#$%&\\*+-.^'_`|~") |b| {
v[b] = true;
}
break :blk v;
};
fn validType(value: []const u8) bool {
for (value) |b| {
if (VALID_CODEPOINTS[b] == false) {
return false;
}
}
return true;
}
fn trimLeft(s: []const u8) []const u8 {
return std.mem.trimLeft(u8, s, &std.ascii.whitespace);
}
fn trimRight(s: []const u8) []const u8 {
return std.mem.trimRight(u8, s, &std.ascii.whitespace);
}
};
mtype: []const u8,
msubtype: []const u8,
params: []const u8 = "",
const testing = @import("../testing.zig");
test "Mime: invalid " {
defer testing.reset();
charset: ?[]const u8 = null,
boundary: ?[]const u8 = null,
pub const Empty = Self{ .mtype = "", .msubtype = "" };
pub const HTML = Self{ .mtype = "text", .msubtype = "html" };
pub const Javascript = Self{ .mtype = "application", .msubtype = "javascript" };
// https://mimesniff.spec.whatwg.org/#http-token-code-point
fn isHTTPCodePoint(c: u8) bool {
return switch (c) {
'!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^' => return true,
'_', '`', '|', '~' => return true,
else => std.ascii.isAlphanumeric(c),
};
}
fn valid(s: []const u8) bool {
const ln = s.len;
var i: usize = 0;
while (i < ln) {
if (!isHTTPCodePoint(s[i])) return false;
i += 1;
}
return true;
}
// https://mimesniff.spec.whatwg.org/#parsing-a-mime-type
pub fn parse(s: []const u8) Self.MimeError!Self {
const ln = s.len;
if (ln == 0) return MimeError.Empty;
// limit input size
if (ln > 255) return MimeError.TooBig;
var res = Self{ .mtype = "", .msubtype = "" };
var r = Reader{ .s = s };
res.mtype = trim(r.until('/'));
if (res.mtype.len == 0) return MimeError.Invalid;
if (!valid(res.mtype)) return MimeError.InvalidChar;
if (!r.skip()) return MimeError.Invalid;
res.msubtype = trim(r.until(';'));
if (res.msubtype.len == 0) return MimeError.Invalid;
if (!valid(res.msubtype)) return MimeError.InvalidChar;
if (!r.skip()) return res;
res.params = trim(r.tail());
if (res.params.len == 0) return MimeError.Invalid;
// parse well known parameters.
// don't check invalid parameter format.
var rp = Reader{ .s = res.params };
while (true) {
const name = trim(rp.until('='));
if (!rp.skip()) return res;
const value = trim(rp.until(';'));
if (std.ascii.eqlIgnoreCase(name, "charset")) {
res.charset = value;
}
if (std.ascii.eqlIgnoreCase(name, "boundary")) {
res.boundary = value;
}
if (!rp.skip()) return res;
}
return res;
}
test "parse valid" {
for ([_][]const u8{
"text/html",
" \ttext/html",
"text \t/html",
"text/ \thtml",
"text/html \t",
}) |tc| {
const m = try Self.parse(tc);
try testing.expectEqualStrings("text", m.mtype);
try testing.expectEqualStrings("html", m.msubtype);
}
const m2 = try Self.parse("text/javascript1.5");
try testing.expectEqualStrings("text", m2.mtype);
try testing.expectEqualStrings("javascript1.5", m2.msubtype);
const m3 = try Self.parse("text/html; charset=utf-8");
try testing.expectEqualStrings("text", m3.mtype);
try testing.expectEqualStrings("html", m3.msubtype);
try testing.expectEqualStrings("charset=utf-8", m3.params);
try testing.expectEqualStrings("utf-8", m3.charset.?);
const m4 = try Self.parse("text/html; boundary=----");
try testing.expectEqualStrings("text", m4.mtype);
try testing.expectEqualStrings("html", m4.msubtype);
try testing.expectEqualStrings("boundary=----", m4.params);
try testing.expectEqualStrings("----", m4.boundary.?);
}
test "parse invalid" {
for ([_][]const u8{
const invalids = [_][]const u8{
"",
"te xt/html;",
"te@xt/html;",
"text/ht@ml;",
"text/html;",
"/text/html",
"/html",
}) |tc| {
_ = Self.parse(tc) catch continue;
try testing.expect(false);
"text",
"text /html",
"text/ html",
"text / html",
"text/html other",
"text/html; x",
"text/html; x=",
"text/html; x= ",
"text/html; = ",
"text/html;=",
"text/html; charset=\"\"",
"text/html; charset=\"",
"text/html; charset=\"\\",
"text/html; charset=\"\\a\"", // invalid to escape non special characters
};
for (invalids) |invalid| {
const mutable_input = try testing.arena_allocator.dupe(u8, invalid);
try testing.expectError(error.Invalid, Mime.parse(undefined, mutable_input));
}
}
// Compare type and subtype.
pub fn eql(self: Self, b: Self) bool {
if (!std.mem.eql(u8, self.mtype, b.mtype)) return false;
return std.mem.eql(u8, self.msubtype, b.msubtype);
test "Mime: parse common" {
defer testing.reset();
try expect(.{ .content_type = .{ .text_xml = {} } }, "text/xml");
try expect(.{ .content_type = .{ .text_html = {} } }, "text/html");
try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain");
try expect(.{ .content_type = .{ .text_xml = {} } }, "text/xml;");
try expect(.{ .content_type = .{ .text_html = {} } }, "text/html;");
try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain;");
try expect(.{ .content_type = .{ .text_xml = {} } }, " \ttext/xml");
try expect(.{ .content_type = .{ .text_html = {} } }, "text/html ");
try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain \t\t");
try expect(.{ .content_type = .{ .text_xml = {} } }, "TEXT/xml");
try expect(.{ .content_type = .{ .text_html = {} } }, "text/Html");
try expect(.{ .content_type = .{ .text_plain = {} } }, "TEXT/PLAIN");
try expect(.{ .content_type = .{ .text_xml = {} } }, " TeXT/xml");
try expect(.{ .content_type = .{ .text_html = {} } }, "teXt/HtML ;");
try expect(.{ .content_type = .{ .text_plain = {} } }, "tExT/PlAiN;");
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");
}
test "Mime: parse uncommon" {
defer testing.reset();
const text_csv = Expectation{
.content_type = .{ .other = .{ .type = "text", .sub_type = "csv" } },
};
try expect(text_csv, "text/csv");
try expect(text_csv, "text/csv;");
try expect(text_csv, " text/csv\t ");
try expect(text_csv, " text/csv\t ;");
try expect(
.{ .content_type = .{ .other = .{ .type = "text", .sub_type = "csv" } } },
"Text/CSV",
);
}
test "Mime: parse charset" {
defer testing.reset();
try expect(.{
.content_type = .{ .text_xml = {} },
.charset = "utf-8",
.params = "charset=utf-8",
}, "text/xml; charset=utf-8");
try expect(.{
.content_type = .{ .text_xml = {} },
.charset = "utf-8",
.params = "charset=\"utf-8\"",
}, "text/xml;charset=\"utf-8\"");
try expect(.{
.content_type = .{ .text_xml = {} },
.charset = "\\ \" ",
.params = "charset=\"\\\\ \\\" \"",
}, "text/xml;charset=\"\\\\ \\\" \" ");
}
test "Mime: isHTML" {
defer testing.reset();
const isHTML = struct {
fn isHTML(expected: bool, input: []const u8) !void {
const mutable_input = try testing.arena_allocator.dupe(u8, input);
var mime = try Mime.parse(testing.arena_allocator, mutable_input);
try testing.expectEqual(expected, mime.isHTML());
}
}.isHTML;
try isHTML(true, "text/html");
try isHTML(true, "text/html;");
try isHTML(true, "text/html; charset=utf-8");
try isHTML(false, "text/htm"); // htm not html
try isHTML(false, "text/plain");
try isHTML(false, "over/9000");
}
test "Mime: sniff" {
try testing.expectEqual(null, Mime.sniff(""));
try testing.expectEqual(null, Mime.sniff("<htm"));
try testing.expectEqual(null, Mime.sniff("<html!"));
try testing.expectEqual(null, Mime.sniff("<a_"));
try testing.expectEqual(null, Mime.sniff("<!doctype html"));
try testing.expectEqual(null, Mime.sniff("<!doctype html>"));
try testing.expectEqual(null, Mime.sniff("\n <!doctype html>"));
try testing.expectEqual(null, Mime.sniff("\n \t <font/>"));
const expectHTML = struct {
fn expect(input: []const u8) !void {
try testing.expectEqual(.text_html, std.meta.activeTag(Mime.sniff(input).?.content_type));
}
}.expect;
try expectHTML("<!doctype html ");
try expectHTML("\n \t <!DOCTYPE HTML ");
try expectHTML("<html ");
try expectHTML("\n \t <HtmL> even more stufff");
try expectHTML("<script>");
try expectHTML("\n \t <SCRIpt >alert(document.cookies)</script>");
try expectHTML("<iframe>");
try expectHTML(" \t <ifRAME >");
try expectHTML("<h1>");
try expectHTML(" <H1>");
try expectHTML("<div>");
try expectHTML("\n\r\r <DiV>");
try expectHTML("<font>");
try expectHTML(" <fonT>");
try expectHTML("<table>");
try expectHTML("\t\t<TAblE>");
try expectHTML("<a>");
try expectHTML("\n\n<A>");
try expectHTML("<style>");
try expectHTML(" \n\t <STyLE>");
try expectHTML("<title>");
try expectHTML(" \n\t <TITLE>");
try expectHTML("<b>");
try expectHTML(" \n\t <B>");
try expectHTML("<body>");
try expectHTML(" \n\t <BODY>");
try expectHTML("<br>");
try expectHTML(" \n\t <BR>");
try expectHTML("<p>");
try expectHTML(" \n\t <P>");
try expectHTML("<!-->");
try expectHTML(" \n\t <!-->");
}
const Expectation = struct {
content_type: Mime.ContentType,
params: []const u8 = "",
charset: ?[]const u8 = null,
};
fn expect(expected: Expectation, input: []const u8) !void {
const mutable_input = try testing.arena_allocator.dupe(u8, input);
const actual = try Mime.parse(testing.arena_allocator, mutable_input);
try testing.expectEqual(
std.meta.activeTag(expected.content_type),
std.meta.activeTag(actual.content_type),
);
switch (expected.content_type) {
.other => |e| {
const a = actual.content_type.other;
try testing.expectEqual(e.type, a.type);
try testing.expectEqual(e.sub_type, a.sub_type);
},
else => {}, // already asserted above
}
try testing.expectEqual(expected.params, actual.params);
if (expected.charset) |ec| {
try testing.expectEqual(ec, actual.charset.?);
} else {
try testing.expectEqual(null, actual.charset);
}
}

View File

@@ -24,11 +24,10 @@ const c = @cImport({
@cInclude("dom/bindings/hubbub/parser.h");
@cInclude("events/event_target.h");
@cInclude("events/event.h");
@cInclude("events/mouse_event.h");
});
const mimalloc = @import("mimalloc");
const Callback = @import("jsruntime").Callback;
const mimalloc = @import("mimalloc.zig");
// init initializes netsurf lib.
// init starts a mimalloc heap arena for the netsurf session. The caller must
@@ -260,7 +259,7 @@ pub const Tag = enum(u8) {
pub fn all() []Tag {
comptime {
const info = @typeInfo(Tag).Enum;
const info = @typeInfo(Tag).@"enum";
var l: [info.fields.len]Tag = undefined;
for (info.fields, 0..) |field, i| {
l[i] = @as(Tag, @enumFromInt(field.value));
@@ -520,6 +519,7 @@ pub fn eventSetInternalType(evt: *Event, internal_type: EventType) !void {
pub const EventType = enum(u8) {
event = 0,
progress_event = 1,
custom_event = 2,
};
pub const MutationEvent = c.dom_mutation_event;
@@ -585,11 +585,61 @@ pub inline fn toEventTarget(comptime T: type, v: *T) *EventTarget {
return @as(*EventTarget, @ptrCast(et_aligned));
}
// The way we implement events is a lot like how Zig implements linked lists.
// A Zig struct contains an `EventNode` field, i.e.:
// node: parser.EventNode,
//
// When eventTargetAddEventListener is called, we pass in `&self.node`.
// This is the pointer that's stored in the netsurf listener and it's the data
// we can get back from the listener. We can call the node's `func` function,
// passing the node itself, and the receiving function will know how to turn
// that node into the our "self", i..e by using @fieldParentPtr.
// https://www.openmymind.net/Zigs-New-LinkedList-API/
pub const EventNode = struct {
// Event id, used for removing. Internal Zig events won't have an id.
// This is normally set to the callback.id for a JavaScript event.
id: ?usize = null,
func: *const fn (node: *EventNode, event: *Event) void,
fn idFromListener(lst: *EventListener) ?usize {
const ctx = eventListenerGetData(lst) orelse return null;
const node: *EventNode = @alignCast(@ptrCast(ctx));
return node.id;
}
};
pub fn eventTargetAddEventListener(
et: *EventTarget,
typ: []const u8,
node: *EventNode,
capture: bool,
) !void {
const event_handler = struct {
fn handle(event_: ?*Event, ptr_: ?*anyopaque) callconv(.C) void {
const ptr = ptr_ orelse return;
const event = event_ orelse return;
const node_: *EventNode = @alignCast(@ptrCast(ptr));
node_.func(node_, event);
}
}.handle;
var listener: ?*EventListener = undefined;
const errLst = c.dom_event_listener_create(event_handler, node, &listener);
try DOMErr(errLst);
defer c.dom_event_listener_unref(listener);
const s = try strFromData(typ);
const err = eventTargetVtable(et).add_event_listener.?(et, s, listener, capture);
try DOMErr(err);
}
pub fn eventTargetHasListener(
et: *EventTarget,
typ: []const u8,
capture: bool,
cbk_id: usize,
id: usize,
) !?*EventListener {
const str = try strFromData(typ);
@@ -614,9 +664,8 @@ pub fn eventTargetHasListener(
// and capture property,
// let's check if the callback handler is the same
defer c.dom_event_listener_unref(listener);
const ehd = EventHandlerDataInternal.fromListener(listener);
if (ehd) |d| {
if (cbk_id == d.data.cbk.id()) {
if (EventNode.idFromListener(listener)) |node_id| {
if (node_id == id) {
return lst;
}
}
@@ -634,127 +683,18 @@ pub fn eventTargetHasListener(
return null;
}
// EventHandlerFunc is a zig function called when the event is dispatched to a
// listener.
// The EventHandlerFunc is responsible to call the callback included into the
// EventHandlerData.
pub const EventHandlerFunc = *const fn (event: ?*Event, data: EventHandlerData) void;
// EventHandler implements the function exposed in C and called by libdom.
// It retrieves the EventHandlerInternalData and call the EventHandlerFunc with
// the EventHandlerData in parameter.
const EventHandler = struct {
fn handle(event: ?*Event, data: ?*anyopaque) callconv(.C) void {
if (data) |d| {
const ehd = EventHandlerDataInternal.get(d);
ehd.handler(event, ehd.data);
// NOTE: we can not call func.deinit here
// b/c the handler can be called several times
// either on this dispatch event or in anoter one
}
}
}.handle;
// EventHandlerData contains a JS callback and the data associated to the
// handler.
// If given, deinitFunc is called with the data pointer to allow the creator to
// clean memory.
// The callback is deinit by EventHandlerDataInternal. It must NOT be deinit
// into deinitFunc.
pub const EventHandlerData = struct {
cbk: Callback,
data: ?*anyopaque = null,
// deinitFunc implements the data deinitialization.
deinitFunc: ?DeinitFunc = null,
pub const DeinitFunc = *const fn (data: ?*anyopaque, alloc: std.mem.Allocator) void;
};
// EventHandlerDataInternal groups the EventHandlerFunc and the EventHandlerData.
const EventHandlerDataInternal = struct {
data: EventHandlerData,
handler: EventHandlerFunc,
fn init(alloc: std.mem.Allocator, handler: EventHandlerFunc, data: EventHandlerData) !*EventHandlerDataInternal {
const ptr = try alloc.create(EventHandlerDataInternal);
ptr.* = .{
.data = data,
.handler = handler,
};
return ptr;
}
fn deinit(self: *EventHandlerDataInternal, alloc: std.mem.Allocator) void {
if (self.data.deinitFunc) |d| d(self.data.data, alloc);
self.data.cbk.deinit(alloc);
alloc.destroy(self);
}
fn get(data: *anyopaque) *EventHandlerDataInternal {
const ptr: *align(@alignOf(*EventHandlerDataInternal)) anyopaque = @alignCast(data);
return @as(*EventHandlerDataInternal, @ptrCast(ptr));
}
// retrieve a EventHandlerDataInternal from a listener.
fn fromListener(lst: *EventListener) ?*EventHandlerDataInternal {
const data = eventListenerGetData(lst);
// free cbk allocation made on eventTargetAddEventListener
if (data == null) return null;
return get(data.?);
}
};
pub fn eventTargetAddEventListener(
et: *EventTarget,
alloc: std.mem.Allocator,
typ: []const u8,
handlerFunc: EventHandlerFunc,
data: EventHandlerData,
capture: bool,
) !void {
// this allocation will be removed either on
// eventTargetRemoveEventListener or eventTargetRemoveAllEventListeners
const ehd = try EventHandlerDataInternal.init(alloc, handlerFunc, data);
errdefer ehd.deinit(alloc);
// When a function is used as an event handler, its this parameter is bound
// to the DOM element on which the listener is placed.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this#this_in_dom_event_handlers
try ehd.data.cbk.setThisArg(et);
const ctx = @as(*anyopaque, @ptrCast(ehd));
var listener: ?*EventListener = undefined;
const errLst = c.dom_event_listener_create(EventHandler, ctx, &listener);
try DOMErr(errLst);
defer c.dom_event_listener_unref(listener);
const s = try strFromData(typ);
const err = eventTargetVtable(et).add_event_listener.?(et, s, listener, capture);
try DOMErr(err);
}
pub fn eventTargetRemoveEventListener(
et: *EventTarget,
alloc: std.mem.Allocator,
typ: []const u8,
lst: *EventListener,
capture: bool,
) !void {
// free data allocation made on eventTargetAddEventListener
const ehd = EventHandlerDataInternal.fromListener(lst);
if (ehd) |d| d.deinit(alloc);
const s = try strFromData(typ);
const err = eventTargetVtable(et).remove_event_listener.?(et, s, lst, capture);
try DOMErr(err);
}
pub fn eventTargetRemoveAllEventListeners(
et: *EventTarget,
alloc: std.mem.Allocator,
) !void {
pub fn eventTargetRemoveAllEventListeners(et: *EventTarget) !void {
var next: ?*EventListenerEntry = undefined;
var lst: ?*EventListener = undefined;
@@ -771,18 +711,16 @@ pub fn eventTargetRemoveAllEventListeners(
try DOMErr(errIter);
if (lst) |listener| {
defer c.dom_event_listener_unref(listener);
const ehd = EventHandlerDataInternal.fromListener(listener);
if (ehd) |d| d.deinit(alloc);
const err = eventTargetVtable(et).remove_event_listener.?(
et,
null,
lst,
false,
);
try DOMErr(err);
if (EventNode.idFromListener(listener) != null) {
defer c.dom_event_listener_unref(listener);
const err = eventTargetVtable(et).remove_event_listener.?(
et,
null,
lst,
false,
);
try DOMErr(err);
}
}
if (next == null) {
@@ -801,10 +739,15 @@ pub fn eventTargetDispatchEvent(et: *EventTarget, event: *Event) !bool {
return res;
}
pub fn elementDispatchEvent(element: *Element, event: *Event) !bool {
const et: *EventTarget = toEventTarget(Element, element);
return eventTargetDispatchEvent(et, @ptrCast(event));
}
pub fn eventTargetTBaseFieldName(comptime T: type) ?[]const u8 {
std.debug.assert(@inComptime());
switch (@typeInfo(T)) {
.Struct => |ti| {
.@"struct" => |ti| {
for (ti.fields) |f| {
if (f.type == EventTargetTBase) return f.name;
}
@@ -860,6 +803,61 @@ pub const EventTargetTBase = extern struct {
}
};
// MouseEvent
pub const MouseEvent = c.dom_mouse_event;
pub fn mouseEventCreate() !*MouseEvent {
var evt: ?*MouseEvent = undefined;
const err = c._dom_mouse_event_create(&evt);
try DOMErr(err);
return evt.?;
}
pub fn mouseEventDestroy(evt: *MouseEvent) void {
c._dom_mouse_event_destroy(evt);
}
const MouseEventOpts = struct {
x: i32,
y: i32,
bubbles: bool = false,
cancelable: bool = false,
ctrl: bool = false,
alt: bool = false,
shift: bool = false,
meta: bool = false,
button: u16 = 0,
click_count: u16 = 1,
};
pub fn mouseEventInit(evt: *MouseEvent, typ: []const u8, opts: MouseEventOpts) !void {
const s = try strFromData(typ);
const err = c._dom_mouse_event_init(
evt,
s,
opts.bubbles,
opts.cancelable,
null, // dom_abstract_view* ?
opts.click_count, // details
opts.x, // screen_x
opts.y, // screen_y
opts.x, // client_x
opts.y, // client_y
opts.ctrl,
opts.alt,
opts.shift,
opts.meta,
opts.button,
null, // related target
);
try DOMErr(err);
}
pub fn mouseEventDefaultPrevented(evt: *MouseEvent) !bool {
return eventDefaultPrevented(@ptrCast(evt));
}
// NodeType
pub const NodeType = enum(u4) {
@@ -1008,6 +1006,7 @@ pub fn nodeLocalName(node: *Node) ![]const u8 {
var s: ?*String = undefined;
const err = nodeVtable(node).dom_node_get_local_name.?(node, &s);
try DOMErr(err);
if (s == null) return "";
var s_lower: ?*String = undefined;
const errStr = c.dom_string_tolower(s, true, &s_lower);
try DOMErr(errStr);
@@ -1098,6 +1097,7 @@ pub fn nodeName(node: *Node) ![]const u8 {
var s: ?*String = undefined;
const err = nodeVtable(node).dom_node_get_node_name.?(node, &s);
try DOMErr(err);
if (s == null) return "";
return strToData(s.?);
}
@@ -1185,8 +1185,8 @@ pub fn nodeInsertBefore(node: *Node, new_node: *Node, ref_node: *Node) !*Node {
return res.?;
}
pub fn nodeIsDefaultNamespace(node: *Node, namespace: []const u8) !bool {
const s = try strFromData(namespace);
pub fn nodeIsDefaultNamespace(node: *Node, namespace_: ?[]const u8) !bool {
const s = if (namespace_) |n| try strFromData(n) else null;
var res: bool = undefined;
const err = nodeVtable(node).dom_node_is_default_namespace.?(node, s, &res);
try DOMErr(err);
@@ -1215,9 +1215,10 @@ pub fn nodeLookupPrefix(node: *Node, namespace: []const u8) !?[]const u8 {
return strToData(s.?);
}
pub fn nodeLookupNamespaceURI(node: *Node, prefix: ?[]const u8) !?[]const u8 {
pub fn nodeLookupNamespaceURI(node: *Node, prefix_: ?[]const u8) !?[]const u8 {
var s: ?*String = undefined;
const err = nodeVtable(node).dom_node_lookup_namespace.?(node, try strFromData(prefix.?), &s);
const prefix: ?*String = if (prefix_) |p| try strFromData(p) else null;
const err = nodeVtable(node).dom_node_lookup_namespace.?(node, prefix, &s);
try DOMErr(err);
if (s == null) return null;
return strToData(s.?);
@@ -1249,11 +1250,11 @@ pub fn nodeHasAttributes(node: *Node) !bool {
return res;
}
pub fn nodeGetAttributes(node: *Node) !*NamedNodeMap {
pub fn nodeGetAttributes(node: *Node) !?*NamedNodeMap {
var res: ?*NamedNodeMap = undefined;
const err = nodeVtable(node).dom_node_get_attributes.?(node, &res);
try DOMErr(err);
return res.?;
return res;
}
pub fn nodeGetNamespace(node: *Node) !?[]const u8 {
@@ -1277,6 +1278,11 @@ pub inline fn nodeToElement(node: *Node) *Element {
return @as(*Element, @ptrCast(node));
}
// nodeToDocument is an helper to convert a node to an document.
pub inline fn nodeToDocument(node: *Node) *Document {
return @as(*Document, @ptrCast(node));
}
// CharacterData
pub const CharacterData = c.dom_characterdata;
@@ -1609,6 +1615,11 @@ pub fn tokenListGetValue(l: *TokenList) !?[]const u8 {
return strToData(res.?);
}
pub fn tokenListSetValue(l: *TokenList, value: []const u8) !void {
const err = c.dom_tokenlist_set_value(l, try strFromData(value));
try DOMErr(err);
}
// ElementHTML
pub const ElementHTML = c.dom_html_element;
@@ -1620,6 +1631,14 @@ pub fn elementHTMLGetTagType(elem_html: *ElementHTML) !Tag {
var tag_type: c.dom_html_element_type = undefined;
const err = elementHTMLVtable(elem_html).dom_html_element_get_tag_type.?(elem_html, &tag_type);
try DOMErr(err);
if (tag_type >= 255) {
// This is questionable, but std.meta.intToEnum has more overhead
// Added this because this WPT test started to fail once we
// introduced an SVGElement:
// html/dom/documents/dom-tree-accessors/document.title-09.html
return Tag.undef;
}
return @as(Tag, @enumFromInt(tag_type));
}
@@ -1794,7 +1813,7 @@ pub fn documentFragmentBodyChildren(doc: *DocumentFragment) !?*NodeList {
// Document Position
pub const DocumentPosition = enum(u2) {
pub const DocumentPosition = enum(u32) {
disconnected = c.DOM_DOCUMENT_POSITION_DISCONNECTED,
preceding = c.DOM_DOCUMENT_POSITION_PRECEDING,
following = c.DOM_DOCUMENT_POSITION_FOLLOWING,
@@ -2179,21 +2198,28 @@ fn parseParams(enc: ?[:0]const u8) c.dom_hubbub_parser_params {
fn parseData(parser: *c.dom_hubbub_parser, reader: anytype) !void {
var err: c.hubbub_error = undefined;
var buffer: [1024]u8 = undefined;
var ln = buffer.len;
while (ln > 0) {
ln = try reader.read(&buffer);
err = c.dom_hubbub_parser_parse_chunk(parser, &buffer, ln);
// TODO handle encoding change error return.
// When the HTML contains a META tag with a different encoding than the
// original one, a c.DOM_HUBBUB_HUBBUB_ERR_ENCODINGCHANGE error is
// returned.
// In this case, we must restart the parsing with the new detected
// encoding. The detected encoding is stored in the document and we can
// get it with documentGetInputEncoding().
try parserErr(err);
const TI = @typeInfo(@TypeOf(reader));
if (TI == .pointer and @hasDecl(TI.pointer.child, "next")) {
while (try reader.next()) |data| {
err = c.dom_hubbub_parser_parse_chunk(parser, data.ptr, data.len);
try parserErr(err);
}
} else {
var buffer: [1024]u8 = undefined;
var ln = buffer.len;
while (ln > 0) {
ln = try reader.read(&buffer);
err = c.dom_hubbub_parser_parse_chunk(parser, &buffer, ln);
// TODO handle encoding change error return.
// When the HTML contains a META tag with a different encoding than the
// original one, a c.DOM_HUBBUB_HUBBUB_ERR_ENCODINGCHANGE error is
// returned.
// In this case, we must restart the parsing with the new detected
// encoding. The detected encoding is stored in the document and we can
// get it with documentGetInputEncoding().
try parserErr(err);
}
}
err = c.dom_hubbub_parser_completed(parser);
try parserErr(err);
}
@@ -2249,3 +2275,35 @@ pub inline fn documentHTMLSetTitle(doc: *DocumentHTML, v: []const u8) !void {
const err = documentHTMLVtable(doc).set_title.?(doc, try strFromData(v));
try DOMErr(err);
}
pub fn documentHTMLSetCurrentScript(doc: *DocumentHTML, script: ?*Script) !void {
var s: ?*ElementHTML = null;
if (script != null) s = @ptrCast(script.?);
const err = documentHTMLVtable(doc).set_current_script.?(doc, s);
try DOMErr(err);
}
pub fn documentHTMLGetCurrentScript(doc: *DocumentHTML) !?*Script {
var elem: ?*ElementHTML = undefined;
const err = documentHTMLVtable(doc).get_current_script.?(doc, &elem);
try DOMErr(err);
if (elem == null) return null;
return @ptrCast(elem.?);
}
pub fn documentHTMLSetLocation(T: type, doc: *DocumentHTML, location: *T) !void {
const l = @as(*anyopaque, @ptrCast(location));
const err = documentHTMLVtable(doc).set_location.?(doc, l);
try DOMErr(err);
}
pub fn documentHTMLGetLocation(T: type, doc: *DocumentHTML) !?*T {
var l: ?*anyopaque = undefined;
const err = documentHTMLVtable(doc).get_location.?(doc, &l);
try DOMErr(err);
if (l == null) return null;
const ptr: *align(@alignOf(*T)) anyopaque = @alignCast(l.?);
return @as(*T, @ptrCast(ptr));
}

View File

@@ -0,0 +1,671 @@
// fetch.js code comes from
// https://github.com/JakeChampion/fetch/blob/main/fetch.js
//
// The original code source is available in MIT license.
//
// The script comes from the built version from npm.
// You can get the package with the command:
//
// wget $(npm view whatwg-fetch dist.tarball)
//
// The source is the content of `package/dist/fetch.umd.js` file.
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(factory((global.WHATWGFetch = {})));
}(this, (function (exports) { 'use strict';
/* eslint-disable no-prototype-builtins */
var g =
(typeof globalThis !== 'undefined' && globalThis) ||
(typeof self !== 'undefined' && self) ||
// eslint-disable-next-line no-undef
(typeof global !== 'undefined' && global) ||
{};
var support = {
searchParams: 'URLSearchParams' in g,
iterable: 'Symbol' in g && 'iterator' in Symbol,
blob:
'FileReader' in g &&
'Blob' in g &&
(function() {
try {
new Blob();
return true
} catch (e) {
return false
}
})(),
formData: 'FormData' in g,
// Arraybuffer is available but xhr doesn't implement it for now.
// arrayBuffer: 'ArrayBuffer' in g
arrayBuffer: false
};
function isDataView(obj) {
return obj && DataView.prototype.isPrototypeOf(obj)
}
if (support.arrayBuffer) {
var viewClasses = [
'[object Int8Array]',
'[object Uint8Array]',
'[object Uint8ClampedArray]',
'[object Int16Array]',
'[object Uint16Array]',
'[object Int32Array]',
'[object Uint32Array]',
'[object Float32Array]',
'[object Float64Array]'
];
var isArrayBufferView =
ArrayBuffer.isView ||
function(obj) {
return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1
};
}
function normalizeName(name) {
if (typeof name !== 'string') {
name = String(name);
}
if (/[^a-z0-9\-#$%&'*+.^_`|~!]/i.test(name) || name === '') {
throw new TypeError('Invalid character in header field name: "' + name + '"')
}
return name.toLowerCase()
}
function normalizeValue(value) {
if (typeof value !== 'string') {
value = String(value);
}
return value
}
// Build a destructive iterator for the value list
function iteratorFor(items) {
var iterator = {
next: function() {
var value = items.shift();
return {done: value === undefined, value: value}
}
};
if (support.iterable) {
iterator[Symbol.iterator] = function() {
return iterator
};
}
return iterator
}
function Headers(headers) {
this.map = {};
if (headers instanceof Headers) {
headers.forEach(function(value, name) {
this.append(name, value);
}, this);
} else if (Array.isArray(headers)) {
headers.forEach(function(header) {
if (header.length != 2) {
throw new TypeError('Headers constructor: expected name/value pair to be length 2, found' + header.length)
}
this.append(header[0], header[1]);
}, this);
} else if (headers) {
Object.getOwnPropertyNames(headers).forEach(function(name) {
this.append(name, headers[name]);
}, this);
}
}
Headers.prototype.append = function(name, value) {
name = normalizeName(name);
value = normalizeValue(value);
var oldValue = this.map[name];
this.map[name] = oldValue ? oldValue + ', ' + value : value;
};
Headers.prototype['delete'] = function(name) {
delete this.map[normalizeName(name)];
};
Headers.prototype.get = function(name) {
name = normalizeName(name);
return this.has(name) ? this.map[name] : null
};
Headers.prototype.has = function(name) {
return this.map.hasOwnProperty(normalizeName(name))
};
Headers.prototype.set = function(name, value) {
this.map[normalizeName(name)] = normalizeValue(value);
};
Headers.prototype.forEach = function(callback, thisArg) {
for (var name in this.map) {
if (this.map.hasOwnProperty(name)) {
callback.call(thisArg, this.map[name], name, this);
}
}
};
Headers.prototype.keys = function() {
var items = [];
this.forEach(function(value, name) {
items.push(name);
});
return iteratorFor(items)
};
Headers.prototype.values = function() {
var items = [];
this.forEach(function(value) {
items.push(value);
});
return iteratorFor(items)
};
Headers.prototype.entries = function() {
var items = [];
this.forEach(function(value, name) {
items.push([name, value]);
});
return iteratorFor(items)
};
if (support.iterable) {
Headers.prototype[Symbol.iterator] = Headers.prototype.entries;
}
function consumed(body) {
if (body._noBody) return
if (body.bodyUsed) {
return Promise.reject(new TypeError('Already read'))
}
body.bodyUsed = true;
}
function fileReaderReady(reader) {
return new Promise(function(resolve, reject) {
reader.onload = function() {
resolve(reader.result);
};
reader.onerror = function() {
reject(reader.error);
};
})
}
function readBlobAsArrayBuffer(blob) {
var reader = new FileReader();
var promise = fileReaderReady(reader);
reader.readAsArrayBuffer(blob);
return promise
}
function readBlobAsText(blob) {
var reader = new FileReader();
var promise = fileReaderReady(reader);
var match = /charset=([A-Za-z0-9_-]+)/.exec(blob.type);
var encoding = match ? match[1] : 'utf-8';
reader.readAsText(blob, encoding);
return promise
}
function readArrayBufferAsText(buf) {
var view = new Uint8Array(buf);
var chars = new Array(view.length);
for (var i = 0; i < view.length; i++) {
chars[i] = String.fromCharCode(view[i]);
}
return chars.join('')
}
function bufferClone(buf) {
if (buf.slice) {
return buf.slice(0)
} else {
var view = new Uint8Array(buf.byteLength);
view.set(new Uint8Array(buf));
return view.buffer
}
}
function Body() {
this.bodyUsed = false;
this._initBody = function(body) {
/*
fetch-mock wraps the Response object in an ES6 Proxy to
provide useful test harness features such as flush. However, on
ES5 browsers without fetch or Proxy support pollyfills must be used;
the proxy-pollyfill is unable to proxy an attribute unless it exists
on the object before the Proxy is created. This change ensures
Response.bodyUsed exists on the instance, while maintaining the
semantic of setting Request.bodyUsed in the constructor before
_initBody is called.
*/
// eslint-disable-next-line no-self-assign
this.bodyUsed = this.bodyUsed;
this._bodyInit = body;
if (!body) {
this._noBody = true;
this._bodyText = '';
} else if (typeof body === 'string') {
this._bodyText = body;
} else if (support.blob && Blob.prototype.isPrototypeOf(body)) {
this._bodyBlob = body;
} else if (support.formData && FormData.prototype.isPrototypeOf(body)) {
this._bodyFormData = body;
} else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) {
this._bodyText = body.toString();
} else if (support.arrayBuffer && support.blob && isDataView(body)) {
this._bodyArrayBuffer = bufferClone(body.buffer);
// IE 10-11 can't handle a DataView body.
this._bodyInit = new Blob([this._bodyArrayBuffer]);
} else if (support.arrayBuffer && (ArrayBuffer.prototype.isPrototypeOf(body) || isArrayBufferView(body))) {
this._bodyArrayBuffer = bufferClone(body);
} else {
this._bodyText = body = Object.prototype.toString.call(body);
}
if (!this.headers.get('content-type')) {
if (typeof body === 'string') {
this.headers.set('content-type', 'text/plain;charset=UTF-8');
} else if (this._bodyBlob && this._bodyBlob.type) {
this.headers.set('content-type', this._bodyBlob.type);
} else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) {
this.headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
}
}
};
if (support.blob) {
this.blob = function() {
var rejected = consumed(this);
if (rejected) {
return rejected
}
if (this._bodyBlob) {
return Promise.resolve(this._bodyBlob)
} else if (this._bodyArrayBuffer) {
return Promise.resolve(new Blob([this._bodyArrayBuffer]))
} else if (this._bodyFormData) {
throw new Error('could not read FormData body as blob')
} else {
return Promise.resolve(new Blob([this._bodyText]))
}
};
}
this.arrayBuffer = function() {
if (this._bodyArrayBuffer) {
var isConsumed = consumed(this);
if (isConsumed) {
return isConsumed
} else if (ArrayBuffer.isView(this._bodyArrayBuffer)) {
return Promise.resolve(
this._bodyArrayBuffer.buffer.slice(
this._bodyArrayBuffer.byteOffset,
this._bodyArrayBuffer.byteOffset + this._bodyArrayBuffer.byteLength
)
)
} else {
return Promise.resolve(this._bodyArrayBuffer)
}
} else if (support.blob) {
return this.blob().then(readBlobAsArrayBuffer)
} else {
throw new Error('could not read as ArrayBuffer')
}
};
this.text = function() {
var rejected = consumed(this);
if (rejected) {
return rejected
}
if (this._bodyBlob) {
return readBlobAsText(this._bodyBlob)
} else if (this._bodyArrayBuffer) {
return Promise.resolve(readArrayBufferAsText(this._bodyArrayBuffer))
} else if (this._bodyFormData) {
throw new Error('could not read FormData body as text')
} else {
return Promise.resolve(this._bodyText)
}
};
if (support.formData) {
this.formData = function() {
return this.text().then(decode)
};
}
this.json = function() {
return this.text().then(JSON.parse)
};
return this
}
// HTTP methods whose capitalization should be normalized
var methods = ['CONNECT', 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'TRACE'];
function normalizeMethod(method) {
var upcased = method.toUpperCase();
return methods.indexOf(upcased) > -1 ? upcased : method
}
function Request(input, options) {
if (!(this instanceof Request)) {
throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.')
}
options = options || {};
var body = options.body;
if (input instanceof Request) {
if (input.bodyUsed) {
throw new TypeError('Already read')
}
this.url = input.url;
this.credentials = input.credentials;
if (!options.headers) {
this.headers = new Headers(input.headers);
}
this.method = input.method;
this.mode = input.mode;
this.signal = input.signal;
if (!body && input._bodyInit != null) {
body = input._bodyInit;
input.bodyUsed = true;
}
} else {
this.url = String(input);
}
this.credentials = options.credentials || this.credentials || 'same-origin';
if (options.headers || !this.headers) {
this.headers = new Headers(options.headers);
}
this.method = normalizeMethod(options.method || this.method || 'GET');
this.mode = options.mode || this.mode || null;
this.signal = options.signal || this.signal || (function () {
if ('AbortController' in g) {
var ctrl = new AbortController();
return ctrl.signal;
}
}());
this.referrer = null;
if ((this.method === 'GET' || this.method === 'HEAD') && body) {
throw new TypeError('Body not allowed for GET or HEAD requests')
}
this._initBody(body);
if (this.method === 'GET' || this.method === 'HEAD') {
if (options.cache === 'no-store' || options.cache === 'no-cache') {
// Search for a '_' parameter in the query string
var reParamSearch = /([?&])_=[^&]*/;
if (reParamSearch.test(this.url)) {
// If it already exists then set the value with the current time
this.url = this.url.replace(reParamSearch, '$1_=' + new Date().getTime());
} else {
// Otherwise add a new '_' parameter to the end with the current time
var reQueryString = /\?/;
this.url += (reQueryString.test(this.url) ? '&' : '?') + '_=' + new Date().getTime();
}
}
}
}
Request.prototype.clone = function() {
return new Request(this, {body: this._bodyInit})
};
function decode(body) {
var form = new FormData();
body
.trim()
.split('&')
.forEach(function(bytes) {
if (bytes) {
var split = bytes.split('=');
var name = split.shift().replace(/\+/g, ' ');
var value = split.join('=').replace(/\+/g, ' ');
form.append(decodeURIComponent(name), decodeURIComponent(value));
}
});
return form
}
function parseHeaders(rawHeaders) {
var headers = new Headers();
// Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space
// https://tools.ietf.org/html/rfc7230#section-3.2
var preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, ' ');
// Avoiding split via regex to work around a common IE11 bug with the core-js 3.6.0 regex polyfill
// https://github.com/github/fetch/issues/748
// https://github.com/zloirock/core-js/issues/751
preProcessedHeaders
.split('\r')
.map(function(header) {
return header.indexOf('\n') === 0 ? header.substr(1, header.length) : header
})
.forEach(function(line) {
var parts = line.split(':');
var key = parts.shift().trim();
if (key) {
var value = parts.join(':').trim();
try {
headers.append(key, value);
} catch (error) {
console.warn('Response ' + error.message);
}
}
});
return headers
}
Body.call(Request.prototype);
function Response(bodyInit, options) {
if (!(this instanceof Response)) {
throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.')
}
if (!options) {
options = {};
}
this.type = 'default';
this.status = options.status === undefined ? 200 : options.status;
if (this.status < 200 || this.status > 599) {
throw new RangeError("Failed to construct 'Response': The status provided (0) is outside the range [200, 599].")
}
this.ok = this.status >= 200 && this.status < 300;
this.statusText = options.statusText === undefined ? '' : '' + options.statusText;
this.headers = new Headers(options.headers);
this.url = options.url || '';
this._initBody(bodyInit);
}
Body.call(Response.prototype);
Response.prototype.clone = function() {
return new Response(this._bodyInit, {
status: this.status,
statusText: this.statusText,
headers: new Headers(this.headers),
url: this.url
})
};
Response.error = function() {
var response = new Response(null, {status: 200, statusText: ''});
response.ok = false;
response.status = 0;
response.type = 'error';
return response
};
var redirectStatuses = [301, 302, 303, 307, 308];
Response.redirect = function(url, status) {
if (redirectStatuses.indexOf(status) === -1) {
throw new RangeError('Invalid status code')
}
return new Response(null, {status: status, headers: {location: url}})
};
exports.DOMException = g.DOMException;
try {
new exports.DOMException();
} catch (err) {
exports.DOMException = function(message, name) {
this.message = message;
this.name = name;
var error = Error(message);
this.stack = error.stack;
};
exports.DOMException.prototype = Object.create(Error.prototype);
exports.DOMException.prototype.constructor = exports.DOMException;
}
function fetch(input, init) {
return new Promise(function(resolve, reject) {
var request = new Request(input, init);
if (request.signal && request.signal.aborted) {
return reject(new exports.DOMException('Aborted', 'AbortError'))
}
var xhr = new XMLHttpRequest();
function abortXhr() {
xhr.abort();
}
xhr.onload = function() {
var options = {
statusText: xhr.statusText,
headers: parseHeaders(xhr.getAllResponseHeaders() || '')
};
// This check if specifically for when a user fetches a file locally from the file system
// Only if the status is out of a normal range
if (request.url.indexOf('file://') === 0 && (xhr.status < 200 || xhr.status > 599)) {
options.status = 200;
} else {
options.status = xhr.status;
}
options.url = 'responseURL' in xhr ? xhr.responseURL : options.headers.get('X-Request-URL');
var body = 'response' in xhr ? xhr.response : xhr.responseText;
setTimeout(function() {
resolve(new Response(body, options));
}, 0);
};
xhr.onerror = function() {
setTimeout(function() {
reject(new TypeError('Network request failed'));
}, 0);
};
xhr.ontimeout = function() {
setTimeout(function() {
reject(new TypeError('Network request timed out'));
}, 0);
};
xhr.onabort = function() {
setTimeout(function() {
reject(new exports.DOMException('Aborted', 'AbortError'));
}, 0);
};
function fixUrl(url) {
try {
return url === '' && g.location.href ? g.location.href : url
} catch (e) {
return url
}
}
xhr.open(request.method, fixUrl(request.url), true);
if (request.credentials === 'include') {
xhr.withCredentials = true;
} else if (request.credentials === 'omit') {
xhr.withCredentials = false;
}
if ('responseType' in xhr) {
if (support.blob) {
xhr.responseType = 'blob';
} else if (
support.arrayBuffer
) {
xhr.responseType = 'arraybuffer';
}
}
if (init && typeof init.headers === 'object' && !(init.headers instanceof Headers || (g.Headers && init.headers instanceof g.Headers))) {
var names = [];
Object.getOwnPropertyNames(init.headers).forEach(function(name) {
names.push(normalizeName(name));
xhr.setRequestHeader(name, normalizeValue(init.headers[name]));
});
request.headers.forEach(function(value, name) {
if (names.indexOf(name) === -1) {
xhr.setRequestHeader(name, value);
}
});
} else {
request.headers.forEach(function(value, name) {
xhr.setRequestHeader(name, value);
});
}
if (request.signal) {
request.signal.addEventListener('abort', abortXhr);
xhr.onreadystatechange = function() {
// DONE (success or failure)
if (xhr.readyState === 4) {
request.signal.removeEventListener('abort', abortXhr);
}
};
}
xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit);
})
}
fetch.polyfill = true;
if (!g.fetch) {
g.fetch = fetch;
g.Headers = Headers;
g.Request = Request;
g.Response = Response;
}
exports.Headers = Headers;
exports.Request = Request;
exports.Response = Response;
exports.fetch = fetch;
Object.defineProperty(exports, '__esModule', { value: true });
})));

View File

@@ -0,0 +1,46 @@
// fetch.js code comes from
// https://github.com/JakeChampion/fetch/blob/main/fetch.js
//
// The original code source is available in MIT license.
//
// The script comes from the built version from npm.
// You can get the package with the command:
//
// wget $(npm view whatwg-fetch dist.tarball)
//
// The source is the content of `package/dist/fetch.umd.js` file.
pub const source = @embedFile("fetch.js");
const testing = @import("../../testing.zig");
test "Browser.fetch" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try @import("polyfill.zig").load(testing.allocator, runner.scope);
try runner.testCases(&.{
.{
\\ var ok = false;
\\ const request = new Request("http://127.0.0.1:9582/loader");
\\ fetch(request).then((response) => { ok = response.ok; });
\\ false;
,
"false",
},
// all events have been resolved.
.{ "ok", "true" },
}, .{});
try runner.testCases(&.{
.{
\\ var ok2 = false;
\\ const request2 = new Request("http://127.0.0.1:9582/loader");
\\ (async function () { resp = await fetch(request2); ok2 = resp.ok; }());
\\ false;
,
"false",
},
// all events have been resolved.
.{ "ok2", "true" },
}, .{});
}

View File

@@ -0,0 +1,54 @@
// 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 builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const Env = @import("../env.zig").Env;
const log = std.log.scoped(.polyfill);
const modules = [_]struct {
name: []const u8,
source: []const u8,
}{
.{ .name = "polyfill-fetch", .source = @import("fetch.zig").source },
};
pub fn load(allocator: Allocator, scope: *Env.Scope) !void {
var try_catch: Env.TryCatch = undefined;
try_catch.init(scope);
defer try_catch.deinit();
for (modules) |m| {
const res = scope.exec(m.source, m.name) catch |err| {
if (try try_catch.err(allocator)) |msg| {
defer allocator.free(msg);
log.err("load {s}: {s}", .{ m.name, msg });
}
return err;
};
if (builtin.mode == .Debug) {
const msg = try res.toString(allocator);
defer allocator.free(msg);
log.debug("load {s}: {s}", .{ m.name, msg });
}
}
}

View File

@@ -0,0 +1,906 @@
const std = @import("std");
const Uri = std.Uri;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const http = @import("../../http/client.zig");
const DateTime = @import("../../datetime.zig").DateTime;
const public_suffix_list = @import("../../data/public_suffix_list.zig").lookup;
const log = std.log.scoped(.cookie);
pub const LookupOpts = struct {
request_time: ?i64 = null,
origin_uri: ?*const Uri = null,
navigation: bool = true,
};
pub const Jar = struct {
allocator: Allocator,
cookies: std.ArrayListUnmanaged(Cookie),
pub fn init(allocator: Allocator) Jar {
return .{
.cookies = .{},
.allocator = allocator,
};
}
pub fn deinit(self: *Jar) void {
for (self.cookies.items) |c| {
c.deinit();
}
self.cookies.deinit(self.allocator);
}
pub fn add(
self: *Jar,
cookie: Cookie,
request_time: i64,
) !void {
const is_expired = isCookieExpired(&cookie, request_time);
defer if (is_expired) {
cookie.deinit();
};
for (self.cookies.items, 0..) |*c, i| {
if (areCookiesEqual(&cookie, c)) {
c.deinit();
if (is_expired) {
_ = self.cookies.swapRemove(i);
} else {
self.cookies.items[i] = cookie;
}
return;
}
}
if (!is_expired) {
try self.cookies.append(self.allocator, cookie);
}
}
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 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();
var first = true;
while (i < cookies.len) {
const cookie = &cookies[i];
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;
} else {
try writer.writeAll("; ");
}
try writeCookie(cookie, writer);
}
}
pub fn populateFromResponse(self: *Jar, uri: *const Uri, header: *const http.ResponseHeader) !void {
const now = std.time.timestamp();
var it = header.iterate("set-cookie");
while (it.next()) |set_cookie| {
const c = Cookie.parse(self.allocator, uri, set_cookie) catch |err| {
log.warn("Couldn't parse cookie '{s}': {}\n", .{ set_cookie, err });
continue;
};
try self.add(c, now);
}
}
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);
}
}
};
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;
}
fn areCookiesEqual(a: *const Cookie, b: *const Cookie) bool {
if (std.mem.eql(u8, a.name, b.name) == false) {
return false;
}
if (std.mem.eql(u8, a.domain, b.domain) == false) {
return false;
}
if (std.mem.eql(u8, a.path, b.path) == false) {
return false;
}
return true;
}
fn areSameSite(origin_uri_: ?*const std.Uri, target_host: []const u8) !bool {
const origin_uri = origin_uri_ orelse return true;
const origin_host = (origin_uri.host orelse return error.InvalidURI).percent_encoded;
// common case
if (std.mem.eql(u8, target_host, origin_host)) {
return true;
}
return std.mem.eql(u8, findSecondLevelDomain(target_host), findSecondLevelDomain(origin_host));
}
fn findSecondLevelDomain(host: []const u8) []const u8 {
var i = std.mem.lastIndexOfScalar(u8, host, '.') orelse return host;
while (true) {
i = std.mem.lastIndexOfScalar(u8, host[0..i], '.') orelse return host;
const strip = i + 1;
if (public_suffix_list(host[strip..]) == false) {
return host[strip..];
}
}
}
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,
const SameSite = enum {
strict,
lax,
none,
};
pub fn deinit(self: *const Cookie) void {
self.arena.deinit();
}
// There's https://datatracker.ietf.org/doc/html/rfc6265 but browsers are
// far less strict. I only found 2 cases where browsers will reject a cookie:
// - a byte 0...32 and 127..255 anywhere in the cookie (the HTTP header
// parser might take care of this already)
// - any shenanigans with the domain attribute - it has to be the current
// domain or one of higher order, exluding TLD.
// Anything else, will turn into a cookie.
// Single value? That's a cookie with an emtpy name and a value
// Key or Values with characters the RFC says aren't allowed? Allowed! (
// (as long as the characters are 32...126)
// Invalid attributes? Ignored.
// Invalid attribute values? Ignore.
// Duplicate attributes - use the last valid
// Value-less attributes with a value? Ignore the value
pub fn parse(allocator: Allocator, uri: *const std.Uri, str: []const u8) !Cookie {
if (str.len == 0) {
// 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) {
return error.InvalidByteSequence;
}
}
const cookie_name, const cookie_value, const rest = parseNameValue(str) catch {
return error.InvalidNameValue;
};
var scrap: [8]u8 = undefined;
var path: ?[]const u8 = null;
var domain: ?[]const u8 = null;
var secure: ?bool = null;
var max_age: ?i64 = null;
var http_only: ?bool = null;
var expires: ?DateTime = null;
var same_site: ?Cookie.SameSite = null;
var it = std.mem.splitScalar(u8, rest, ';');
while (it.next()) |attribute| {
const sep = std.mem.indexOfScalarPos(u8, attribute, 0, '=') orelse attribute.len;
const key_string = trim(attribute[0..sep]);
if (key_string.len > 8) {
// not valid, ignore
continue;
}
// Make sure no one changes our max length without also expanding the size of scrap
std.debug.assert(key_string.len <= 8);
const key = std.meta.stringToEnum(enum {
path,
domain,
secure,
@"max-age",
expires,
httponly,
samesite,
}, std.ascii.lowerString(&scrap, key_string)) orelse continue;
var 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) {
// can't set a cookie for a TLD
return error.InvalidDomain;
}
if (std.mem.endsWith(u8, host, value) == false) {
return error.InvalidDomain;
}
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,
.httponly => http_only = true,
.samesite => {
same_site = std.meta.stringToEnum(Cookie.SameSite, std.ascii.lowerString(&scrap, value)) orelse continue;
},
}
}
if (same_site == .none and secure == null) {
return error.InsecureSameSite;
}
var arena = ArenaAllocator.init(allocator);
errdefer arena.deinit();
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_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;
if (max_age) |ma| {
normalized_expires = std.time.timestamp() + ma;
} else {
// max age takes priority over expires
if (expires) |e| {
normalized_expires = e.sub(DateTime.now(), .seconds);
}
}
return .{
.arena = arena,
.name = owned_name,
.value = owned_value,
.path = owned_path,
.same_site = same_site orelse .lax,
.secure = secure orelse false,
.http_only = http_only orelse false,
.domain = owned_domain,
.expires = normalized_expires,
};
}
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 ..];
const sep = std.mem.indexOfScalarPos(u8, str[0..key_value_end], 0, '=') orelse {
const value = trim(str[0..key_value_end]);
if (value.len == 0) {
return error.Empty;
}
return .{ "", value, rest };
};
const name = trim(str[0..sep]);
const value = trim(str[sep + 1 .. key_value_end]);
return .{ name, value, rest };
}
};
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]);
}
fn trim(str: []const u8) []const u8 {
return std.mem.trim(u8, str, &std.ascii.whitespace);
}
fn trimLeft(str: []const u8) []const u8 {
return std.mem.trimLeft(u8, str, &std.ascii.whitespace);
}
fn trimRight(str: []const u8) []const u8 {
return std.mem.trimLeft(u8, str, &std.ascii.whitespace);
}
const testing = @import("../../testing.zig");
test "cookie: findSecondLevelDomain" {
const cases = [_]struct { []const u8, []const u8 }{
.{ "", "" },
.{ "com", "com" },
.{ "lightpanda.io", "lightpanda.io" },
.{ "lightpanda.io", "test.lightpanda.io" },
.{ "lightpanda.io", "first.test.lightpanda.io" },
.{ "www.gov.uk", "www.gov.uk" },
.{ "stats.gov.uk", "www.stats.gov.uk" },
.{ "api.gov.uk", "api.gov.uk" },
.{ "dev.api.gov.uk", "dev.api.gov.uk" },
.{ "dev.api.gov.uk", "1.dev.api.gov.uk" },
};
for (cases) |c| {
try testing.expectEqual(c.@"0", findSecondLevelDomain(c.@"1"));
}
}
test "Jar: add" {
const expectCookies = struct {
fn expect(expected: []const struct { []const u8, []const u8 }, jar: Jar) !void {
try testing.expectEqual(expected.len, jar.cookies.items.len);
LOOP: for (expected) |e| {
for (jar.cookies.items) |c| {
if (std.mem.eql(u8, e.@"0", c.name) and std.mem.eql(u8, e.@"1", c.value)) {
continue :LOOP;
}
}
std.debug.print("Cookie ({s}={s}) not found", .{ e.@"0", e.@"1" });
return error.CookieNotFound;
}
}
}.expect;
const now = std.time.timestamp();
var jar = Jar.init(testing.allocator);
defer jar.deinit();
try expectCookies(&.{}, jar);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9000;Max-Age=0"), now);
try expectCookies(&.{}, jar);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9000"), now);
try expectCookies(&.{.{ "over", "9000" }}, jar);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9000!!"), now);
try expectCookies(&.{.{ "over", "9000!!" }}, jar);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "spice=flow"), now);
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flow" } }, jar);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "spice=flows;Path=/"), now);
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" } }, jar);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9001;Path=/other"), now);
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9001" } }, jar);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9002;Path=/;Domain=lightpanda.io"), now);
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9001" }, .{ "over", "9002" } }, jar);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=x;Path=/other;Max-Age=-200"), now);
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9002" } }, jar);
}
test "Jar: forRequest" {
const expectCookies = struct {
fn expect(expected: []const u8, jar: *Jar, target_uri: Uri, opts: LookupOpts) !void {
var arr: std.ArrayListUnmanaged(u8) = .{};
defer arr.deinit(testing.allocator);
try jar.forRequest(&target_uri, arr.writer(testing.allocator), opts);
try testing.expectEqual(expected, arr.items);
}
}.expect;
const now = std.time.timestamp();
var jar = Jar.init(testing.allocator);
defer jar.deinit();
const test_uri_2 = Uri.parse("http://test.lightpanda.io/") catch unreachable;
{
// test with no cookies
try expectCookies("", &jar, test_uri, .{});
}
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "global1=1"), now);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "global2=2;Max-Age=30;domain=lightpanda.io"), now);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "path1=3;Path=/about"), now);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "path2=4;Path=/docs/"), now);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "secure=5;Secure"), now);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "sitenone=6;SameSite=None;Path=/x/;Secure"), now);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "sitelax=7;SameSite=Lax;Path=/x/"), now);
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "sitestrict=8;SameSite=Strict;Path=/x/"), now);
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 });
// 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,
});
// 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,
});
// incomplete prefix path
try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/abou"), .{
.origin_uri = &test_uri,
});
// path doesn't match
try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/aboutus"), .{
.origin_uri = &test_uri,
});
// 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,
});
// exact directory match
try expectCookies("global1=1; global2=2; path2=4", &jar, try std.Uri.parse("http://lightpanda.io/docs/"), .{
.origin_uri = &test_uri,
});
// 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,
});
// secure
try expectCookies("global1=1; global2=2; secure=5", &jar, try std.Uri.parse("https://lightpanda.io/"), .{
.origin_uri = &test_uri,
});
// 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/")),
});
// 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/")),
});
// 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,
});
// 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,
});
// 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,
});
// exact domain match + suffix
try expectCookies("global2=2; domain1=9", &jar, try std.Uri.parse("http://test.lightpanda.io/"), .{
.origin_uri = &test_uri,
});
// domain suffix match + suffix
try expectCookies("global2=2; domain1=9", &jar, try std.Uri.parse("http://1.test.lightpanda.io/"), .{
.origin_uri = &test_uri,
});
// non-matching domain
try expectCookies("global2=2", &jar, try std.Uri.parse("http://other.lightpanda.io/"), .{
.origin_uri = &test_uri,
});
const l = jar.cookies.items.len;
try expectCookies("global1=1", &jar, test_uri, .{
.request_time = now + 100,
.origin_uri = &test_uri,
});
try testing.expectEqual(l - 1, jar.cookies.items.len);
// If you add more cases after this point, note that the above test removes
// 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' });
try expectError(error.InvalidByteSequence, null, &.{ 'a', 127, '=', 'b' });
try expectError(error.InvalidByteSequence, null, &.{ 'a', '=', 'b', 20 });
try expectError(error.InvalidByteSequence, null, &.{ 'a', '=', 'b', 128 });
try expectAttribute(.{ .name = "", .value = "a" }, null, "a");
try expectAttribute(.{ .name = "", .value = "a" }, null, "a;");
try expectAttribute(.{ .name = "", .value = "a b" }, null, "a b");
try expectAttribute(.{ .name = "a b", .value = "b" }, null, "a b=b");
try expectAttribute(.{ .name = "a,", .value = "b" }, null, "a,=b");
try expectAttribute(.{ .name = ":a>", .value = "b>><" }, null, ":a>=b>><");
try expectAttribute(.{ .name = "abc", .value = "" }, null, "abc=");
try expectAttribute(.{ .name = "abc", .value = "" }, null, "abc=;");
try expectAttribute(.{ .name = "a", .value = "b" }, null, "a=b");
try expectAttribute(.{ .name = "a", .value = "b" }, null, "a=b;");
try expectAttribute(.{ .name = "abc", .value = "fe f" }, null, "abc= fe f");
try expectAttribute(.{ .name = "abc", .value = "fe f" }, null, "abc= fe f ");
try expectAttribute(.{ .name = "abc", .value = "fe f" }, null, "abc= fe f;");
try expectAttribute(.{ .name = "abc", .value = "fe f" }, null, "abc= fe f ;");
try expectAttribute(.{ .name = "abc", .value = "\" fe f\"" }, null, "abc=\" fe f\"");
try expectAttribute(.{ .name = "abc", .value = "\" fe f \"" }, null, "abc=\" fe f \"");
try expectAttribute(.{ .name = "ab4344c", .value = "1ads23" }, null, " ab4344c=1ads23 ");
try expectAttribute(.{ .name = "ab4344c", .value = "1ads23" }, null, " ab4344c = 1ads23 ;");
}
test "Cookie: parse path" {
try expectAttribute(.{ .path = "/" }, "http://a/", "b");
try expectAttribute(.{ .path = "/" }, "http://a/", "b;path");
try expectAttribute(.{ .path = "/" }, "http://a/", "b;Path=");
try expectAttribute(.{ .path = "/" }, "http://a/", "b;Path=;");
try expectAttribute(.{ .path = "/" }, "http://a/", "b; Path=other");
try expectAttribute(.{ .path = "/" }, "http://a/23", "b; path=other ");
try expectAttribute(.{ .path = "/" }, "http://a/abc", "b");
try expectAttribute(.{ .path = "/abc" }, "http://a/abc/", "b");
try expectAttribute(.{ .path = "/abc" }, "http://a/abc/123", "b");
try expectAttribute(.{ .path = "/abc/123" }, "http://a/abc/123/", "b");
try expectAttribute(.{ .path = "/a" }, "http://a/", "b;Path=/a");
try expectAttribute(.{ .path = "/aa" }, "http://a/", "b;path=/aa;");
try expectAttribute(.{ .path = "/aabc/" }, "http://a/", "b; path= /aabc/ ;");
try expectAttribute(.{ .path = "/bbb/" }, "http://a/", "b; path=/a/; path=/bbb/");
try expectAttribute(.{ .path = "/cc" }, "http://a/", "b; path=/a/; path=/bbb/; path = /cc");
}
test "Cookie: parse secure" {
try expectAttribute(.{ .secure = false }, null, "b");
try expectAttribute(.{ .secure = false }, null, "b;secured");
try expectAttribute(.{ .secure = false }, null, "b;security");
try expectAttribute(.{ .secure = false }, null, "b;SecureX");
try expectAttribute(.{ .secure = true }, null, "b; Secure");
try expectAttribute(.{ .secure = true }, null, "b; Secure ");
try expectAttribute(.{ .secure = true }, null, "b; Secure=on ");
try expectAttribute(.{ .secure = true }, null, "b; Secure=Off ");
try expectAttribute(.{ .secure = true }, null, "b; secure=Off ");
try expectAttribute(.{ .secure = true }, null, "b; seCUre=Off ");
}
test "Cookie: parse HttpOnly" {
try expectAttribute(.{ .http_only = false }, null, "b");
try expectAttribute(.{ .http_only = false }, null, "b;HttpOnly0");
try expectAttribute(.{ .http_only = false }, null, "b;H ttpOnly");
try expectAttribute(.{ .http_only = true }, null, "b; HttpOnly");
try expectAttribute(.{ .http_only = true }, null, "b; Httponly ");
try expectAttribute(.{ .http_only = true }, null, "b; Httponly=on ");
try expectAttribute(.{ .http_only = true }, null, "b; httpOnly=Off ");
try expectAttribute(.{ .http_only = true }, null, "b; httpOnly=Off ");
try expectAttribute(.{ .http_only = true }, null, "b; HttpOnly=Off ");
}
test "Cookie: parse SameSite" {
try expectAttribute(.{ .same_site = .lax }, null, "b;samesite");
try expectAttribute(.{ .same_site = .lax }, null, "b;samesite=lax");
try expectAttribute(.{ .same_site = .lax }, null, "b; SameSite=Lax ");
try expectAttribute(.{ .same_site = .lax }, null, "b; SameSite=Other ");
try expectAttribute(.{ .same_site = .lax }, null, "b; SameSite=Nope ");
// SameSite=none is only valid when Secure is set. The whole cookie is
// rejected otherwise
try expectError(error.InsecureSameSite, null, "b;samesite=none");
try expectError(error.InsecureSameSite, null, "b;SameSite=None");
try expectAttribute(.{ .same_site = .none }, null, "b; samesite=none; secure ");
try expectAttribute(.{ .same_site = .none }, null, "b; SameSite=None ; SECURE");
try expectAttribute(.{ .same_site = .none }, null, "b;Secure; SameSite=None");
try expectAttribute(.{ .same_site = .none }, null, "b; SameSite=None; Secure");
try expectAttribute(.{ .same_site = .strict }, null, "b; samesite=Strict ");
try expectAttribute(.{ .same_site = .strict }, null, "b; SameSite= STRICT ");
try expectAttribute(.{ .same_site = .strict }, null, "b; SameSITE=strict;");
try expectAttribute(.{ .same_site = .strict }, null, "b; SameSite=Strict");
try expectAttribute(.{ .same_site = .strict }, null, "b; SameSite=None; SameSite=lax; SameSite=Strict");
}
test "Cookie: parse max-age" {
try expectAttribute(.{ .expires = null }, null, "b;max-age");
try expectAttribute(.{ .expires = null }, null, "b;max-age=abc");
try expectAttribute(.{ .expires = null }, null, "b;max-age=13.22");
try expectAttribute(.{ .expires = null }, null, "b;max-age=13abc");
try expectAttribute(.{ .expires = std.time.timestamp() + 13 }, null, "b;max-age=13");
try expectAttribute(.{ .expires = std.time.timestamp() + -22 }, null, "b;max-age=-22");
try expectAttribute(.{ .expires = std.time.timestamp() + 4294967296 }, null, "b;max-age=4294967296");
try expectAttribute(.{ .expires = std.time.timestamp() + -4294967296 }, null, "b;Max-Age= -4294967296");
try expectAttribute(.{ .expires = std.time.timestamp() + 0 }, null, "b; Max-Age=0");
try expectAttribute(.{ .expires = std.time.timestamp() + 500 }, null, "b; Max-Age = 500 ; Max-Age=invalid");
try expectAttribute(.{ .expires = std.time.timestamp() + 1000 }, null, "b;max-age=600;max-age=0;max-age = 1000");
}
test "Cookie: parse expires" {
try expectAttribute(.{ .expires = null }, null, "b;expires=");
try expectAttribute(.{ .expires = null }, null, "b;expires=abc");
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");
// 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");
}
test "Cookie: parse all" {
try expectCookie(.{
.name = "user-id",
.value = "9000",
.path = "/cms",
.domain = "lightpanda.io",
}, "https://lightpanda.io/cms/users", "user-id=9000");
try expectCookie(.{
.name = "user-id",
.value = "9000",
.path = "/",
.http_only = true,
.secure = true,
.domain = ".lightpanda.io",
.expires = std.time.timestamp() + 30,
}, "https://lightpanda.io/cms/users", "user-id=9000; HttpOnly; Max-Age=30; Secure; path=/; Domain=lightpanda.io");
}
test "Cookie: parse domain" {
try expectAttribute(.{ .domain = "lightpanda.io" }, "http://lightpanda.io/", "b");
try expectAttribute(.{ .domain = "dev.lightpanda.io" }, "http://dev.lightpanda.io/", "b");
try expectAttribute(.{ .domain = ".lightpanda.io" }, "http://lightpanda.io/", "b;domain=lightpanda.io");
try expectAttribute(.{ .domain = ".lightpanda.io" }, "http://lightpanda.io/", "b;domain=.lightpanda.io");
try expectAttribute(.{ .domain = ".dev.lightpanda.io" }, "http://dev.lightpanda.io/", "b;domain=dev.lightpanda.io");
try expectAttribute(.{ .domain = ".lightpanda.io" }, "http://dev.lightpanda.io/", "b;domain=lightpanda.io");
try expectAttribute(.{ .domain = ".lightpanda.io" }, "http://dev.lightpanda.io/", "b;domain=.lightpanda.io");
try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=io");
try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=.io");
try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=other.lightpanda.io");
try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=other.lightpanda.com");
try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=other.example.com");
}
const ExpectedCookie = struct {
name: []const u8,
value: []const u8,
path: []const u8,
domain: []const u8,
expires: ?i64 = null,
secure: bool = false,
http_only: bool = false,
same_site: Cookie.SameSite = .lax,
};
fn expectCookie(expected: ExpectedCookie, url: []const u8, set_cookie: []const u8) !void {
const uri = try Uri.parse(url);
var cookie = try Cookie.parse(testing.allocator, &uri, set_cookie);
defer cookie.deinit();
try testing.expectEqual(expected.name, cookie.name);
try testing.expectEqual(expected.value, cookie.value);
try testing.expectEqual(expected.secure, cookie.secure);
try testing.expectEqual(expected.http_only, cookie.http_only);
try testing.expectEqual(expected.same_site, cookie.same_site);
try testing.expectEqual(expected.path, cookie.path);
try testing.expectEqual(expected.domain, cookie.domain);
try testing.expectDelta(expected.expires, cookie.expires, 2);
}
fn expectAttribute(expected: anytype, url: ?[]const u8, set_cookie: []const u8) !void {
const uri = if (url) |u| try Uri.parse(u) else test_uri;
var cookie = try Cookie.parse(testing.allocator, &uri, set_cookie);
defer cookie.deinit();
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);
} else {
try testing.expectEqual(@field(expected, f.name), @field(cookie, f.name));
}
}
}
fn expectError(expected: anyerror, url: ?[]const u8, set_cookie: []const u8) !void {
const uri = if (url) |u| try Uri.parse(u) else test_uri;
try testing.expectError(expected, Cookie.parse(testing.allocator, &uri, set_cookie));
}
const test_uri = Uri.parse("http://lightpanda.io/") catch unreachable;

View File

@@ -18,18 +18,17 @@
const std = @import("std");
const jsruntime = @import("jsruntime");
const Case = jsruntime.test_utils.Case;
const checkCases = jsruntime.test_utils.checkCases;
const generate = @import("../generate.zig");
const DOMError = @import("netsurf").DOMError;
const DOMError = @import("../netsurf.zig").DOMError;
const log = std.log.scoped(.storage);
pub const Interfaces = generate.Tuple(.{
pub const cookie = @import("cookie.zig");
pub const Cookie = cookie.Cookie;
pub const CookieJar = cookie.Jar;
pub const Interfaces = .{
Bottle,
});
};
// See https://storage.spec.whatwg.org/#model for storage hierarchy.
// A Shed contains map of Shelves. The key is the document's origin.
@@ -101,7 +100,6 @@ pub const Bucket = struct {
// https://html.spec.whatwg.org/multipage/webstorage.html#the-storage-interface
pub const Bottle = struct {
pub const mem_guarantied = true;
const Map = std.StringHashMapUnmanaged([]const u8);
// allocator is stored. we don't use the JS env allocator b/c the storage
@@ -151,20 +149,22 @@ pub const Bottle = struct {
}
pub fn _setItem(self: *Bottle, k: []const u8, v: []const u8) !void {
const old = self.map.get(k);
if (old != null and std.mem.eql(u8, v, old.?)) return;
// owns k and v by copying them.
const kk = try self.alloc.dupe(u8, k);
errdefer self.alloc.free(kk);
const vv = try self.alloc.dupe(u8, v);
errdefer self.alloc.free(vv);
self.map.put(self.alloc, kk, vv) catch |e| {
const gop = self.map.getOrPut(self.alloc, k) catch |e| {
log.debug("set item: {any}", .{e});
return DOMError.QuotaExceeded;
};
if (gop.found_existing == false) {
gop.key_ptr.* = try self.alloc.dupe(u8, k);
gop.value_ptr.* = try self.alloc.dupe(u8, v);
return;
}
if (std.mem.eql(u8, v, gop.value_ptr.*) == false) {
self.alloc.free(gop.value_ptr.*);
gop.value_ptr.* = try self.alloc.dupe(u8, v);
}
// > Broadcast this with key, oldValue, and value.
// https://html.spec.whatwg.org/multipage/webstorage.html#the-storageevent-interface
//
@@ -173,12 +173,14 @@ pub const Bottle = struct {
// > context of another document.
// https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
//
// So for now, we won't impement the feature.
// So for now, we won't implement the feature.
}
pub fn _removeItem(self: *Bottle, k: []const u8) !void {
const old = self.map.fetchRemove(k);
if (old == null) return;
if (self.map.fetchRemove(k)) |kv| {
self.alloc.free(kv.key);
self.alloc.free(kv.value);
}
// > Broadcast this with key, oldValue, and null.
// https://html.spec.whatwg.org/multipage/webstorage.html#the-storageevent-interface
@@ -210,41 +212,44 @@ pub const Bottle = struct {
// Tests
// -----
pub fn testExecFn(
_: std.mem.Allocator,
js_env: *jsruntime.Env,
) anyerror!void {
var storage = [_]Case{
.{ .src = "localStorage.length", .ex = "0" },
const testing = @import("../../testing.zig");
test "Browser.Storage.LocalStorage" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
.{ .src = "localStorage.setItem('foo', 'bar')", .ex = "undefined" },
.{ .src = "localStorage.length", .ex = "1" },
.{ .src = "localStorage.getItem('foo')", .ex = "bar" },
.{ .src = "localStorage.removeItem('foo')", .ex = "undefined" },
.{ .src = "localStorage.length", .ex = "0" },
try runner.testCases(&.{
.{ "localStorage.length", "0" },
// .{ .src = "localStorage['foo'] = 'bar'", .ex = "undefined" },
// .{ .src = "localStorage['foo']", .ex = "bar" },
// .{ .src = "localStorage.length", .ex = "1" },
.{ "localStorage.setItem('foo', 'bar')", "undefined" },
.{ "localStorage.length", "1" },
.{ "localStorage.getItem('foo')", "bar" },
.{ "localStorage.removeItem('foo')", "undefined" },
.{ "localStorage.length", "0" },
.{ .src = "localStorage.clear()", .ex = "undefined" },
.{ .src = "localStorage.length", .ex = "0" },
};
try checkCases(js_env, &storage);
// .{ "localStorage['foo'] = 'bar'", "undefined" },
// .{ "localStorage['foo']", "bar" },
// .{ "localStorage.length", "1" },
.{ "localStorage.clear()", "undefined" },
.{ "localStorage.length", "0" },
}, .{});
}
test "storage bottle" {
var bottle = Bottle.init(std.testing.allocator);
defer bottle.deinit();
try std.testing.expect(0 == bottle.get_length());
try std.testing.expect(null == bottle._getItem("foo"));
try std.testing.expectEqual(0, bottle.get_length());
try std.testing.expectEqual(null, bottle._getItem("foo"));
try bottle._setItem("foo", "bar");
try std.testing.expect(std.mem.eql(u8, "bar", bottle._getItem("foo").?));
try std.testing.expectEqualStrings("bar", bottle._getItem("foo").?);
try bottle._setItem("foo", "other");
try std.testing.expectEqualStrings("other", bottle._getItem("foo").?);
try bottle._removeItem("foo");
try std.testing.expect(0 == bottle.get_length());
try std.testing.expect(null == bottle._getItem("foo"));
try std.testing.expectEqual(0, bottle.get_length());
try std.testing.expectEqual(null, bottle._getItem("foo"));
}

433
src/browser/url/query.zig Normal file
View File

@@ -0,0 +1,433 @@
// 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 Reader = @import("../../str/parser.zig").Reader;
const asUint = @import("../../str/parser.zig").asUint;
// Values is a map with string key of string values.
pub const Values = struct {
arena: std.heap.ArenaAllocator,
map: std.StringArrayHashMapUnmanaged(List),
const List = std.ArrayListUnmanaged([]const u8);
pub fn init(allocator: std.mem.Allocator) Values {
return .{
.map = .{},
.arena = std.heap.ArenaAllocator.init(allocator),
};
}
pub fn deinit(self: *Values) void {
self.arena.deinit();
}
// add the key value couple to the values.
// the key and the value are duplicated.
pub fn append(self: *Values, k: []const u8, v: []const u8) !void {
const allocator = self.arena.allocator();
const owned_value = try allocator.dupe(u8, v);
var gop = try self.map.getOrPut(allocator, k);
if (gop.found_existing) {
return gop.value_ptr.append(allocator, owned_value);
}
gop.key_ptr.* = try allocator.dupe(u8, k);
var list = List{};
try list.append(allocator, owned_value);
gop.value_ptr.* = list;
}
// append by taking the ownership of the key and the value
fn appendOwned(self: *Values, k: []const u8, v: []const u8) !void {
const allocator = self.arena.allocator();
var gop = try self.map.getOrPut(allocator, k);
if (gop.found_existing) {
return gop.value_ptr.append(allocator, v);
}
var list = List{};
try list.append(allocator, v);
gop.value_ptr.* = list;
}
pub fn get(self: *const Values, k: []const u8) []const []const u8 {
if (self.map.get(k)) |list| {
return list.items;
}
return &[_][]const u8{};
}
pub fn first(self: *const Values, k: []const u8) []const u8 {
if (self.map.getPtr(k)) |list| {
if (list.items.len == 0) return "";
return list.items[0];
}
return "";
}
pub fn delete(self: *Values, k: []const u8) void {
_ = self.map.fetchSwapRemove(k);
}
pub fn deleteValue(self: *Values, k: []const u8, v: []const u8) void {
const list = self.map.getPtr(k) orelse return;
for (list.items, 0..) |vv, i| {
if (std.mem.eql(u8, v, vv)) {
_ = list.swapRemove(i);
return;
}
}
}
pub fn count(self: *const Values) usize {
return self.map.count();
}
pub fn encode(self: *const Values, writer: anytype) !void {
var it = self.map.iterator();
const first_entry = it.next() orelse return;
try encodeKeyValues(first_entry, writer);
while (it.next()) |entry| {
try writer.writeByte('&');
try encodeKeyValues(entry, writer);
}
}
};
fn encodeKeyValues(entry: anytype, writer: anytype) !void {
const key = entry.key_ptr.*;
try escape(key, writer);
const values = entry.value_ptr.items;
if (values.len == 0) {
return;
}
if (values[0].len > 0) {
try writer.writeByte('=');
try escape(values[0], writer);
}
for (values[1..]) |value| {
try writer.writeByte('&');
try escape(key, writer);
if (value.len > 0) {
try writer.writeByte('=');
try escape(value, writer);
}
}
}
fn escape(raw: []const u8, writer: anytype) !void {
var start: usize = 0;
for (raw, 0..) |char, index| {
if ('a' <= char and char <= 'z' or 'A' <= char and char <= 'Z' or '0' <= char and char <= '9') {
continue;
}
try writer.print("{s}%{X:0>2}", .{ raw[start..index], char });
start = index + 1;
}
try writer.writeAll(raw[start..]);
}
// Parse the given query.
pub fn parseQuery(alloc: std.mem.Allocator, s: []const u8) !Values {
var values = Values.init(alloc);
errdefer values.deinit();
const arena = values.arena.allocator();
const ln = s.len;
if (ln == 0) return values;
var r = Reader{ .data = s };
while (true) {
const param = r.until('&');
if (param.len == 0) break;
var rr = Reader{ .data = param };
const k = rr.until('=');
if (k.len == 0) continue;
_ = rr.skip();
const v = rr.tail();
// decode k and v
const kk = try unescape(arena, k);
const vv = try unescape(arena, v);
try values.appendOwned(kk, vv);
if (!r.skip()) break;
}
return values;
}
// The return'd string may or may not be allocated. Callers should use arenas
fn unescape(allocator: std.mem.Allocator, input: []const u8) ![]const u8 {
const HEX_CHAR = comptime blk: {
var all = std.mem.zeroes([256]bool);
for ('a'..('f' + 1)) |b| all[b] = true;
for ('A'..('F' + 1)) |b| all[b] = true;
for ('0'..('9' + 1)) |b| all[b] = true;
break :blk all;
};
const HEX_DECODE = comptime blk: {
var all = std.mem.zeroes([256]u8);
for ('a'..('z' + 1)) |b| all[b] = b - 'a' + 10;
for ('A'..('Z' + 1)) |b| all[b] = b - 'A' + 10;
for ('0'..('9' + 1)) |b| all[b] = b - '0';
break :blk all;
};
var has_plus = false;
var unescaped_len = input.len;
{
// Figure out if we have any spaces and what the final unescaped length
// will be (which will let us know if we have anything to unescape in
// the first place)
var i: usize = 0;
while (i < input.len) {
const c = input[i];
if (c == '%') {
if (i + 2 >= input.len or !HEX_CHAR[input[i + 1]] or !HEX_CHAR[input[i + 2]]) {
return error.EscapeError;
}
i += 3;
unescaped_len -= 2;
} else if (c == '+') {
has_plus = true;
i += 1;
} else {
i += 1;
}
}
}
// no encoding, and no plus. nothing to unescape
if (unescaped_len == input.len and has_plus == false) {
return input;
}
var unescaped = try allocator.alloc(u8, unescaped_len);
errdefer allocator.free(unescaped);
var input_pos: usize = 0;
for (0..unescaped_len) |unescaped_pos| {
switch (input[input_pos]) {
'+' => {
unescaped[unescaped_pos] = ' ';
input_pos += 1;
},
'%' => {
const encoded = input[input_pos + 1 .. input_pos + 3];
const encoded_as_uint = @as(u16, @bitCast(encoded[0..2].*));
unescaped[unescaped_pos] = switch (encoded_as_uint) {
asUint("20") => ' ',
asUint("21") => '!',
asUint("22") => '"',
asUint("23") => '#',
asUint("24") => '$',
asUint("25") => '%',
asUint("26") => '&',
asUint("27") => '\'',
asUint("28") => '(',
asUint("29") => ')',
asUint("2A") => '*',
asUint("2B") => '+',
asUint("2C") => ',',
asUint("2F") => '/',
asUint("3A") => ':',
asUint("3B") => ';',
asUint("3D") => '=',
asUint("3F") => '?',
asUint("40") => '@',
asUint("5B") => '[',
asUint("5D") => ']',
else => HEX_DECODE[encoded[0]] << 4 | HEX_DECODE[encoded[1]],
};
input_pos += 3;
},
else => |c| {
unescaped[unescaped_pos] = c;
input_pos += 1;
},
}
}
return unescaped;
}
const testing = std.testing;
test "url.Query: unescape" {
const allocator = testing.allocator;
const cases = [_]struct { expected: []const u8, input: []const u8, free: bool }{
.{ .expected = "", .input = "", .free = false },
.{ .expected = "over", .input = "over", .free = false },
.{ .expected = "Hello World", .input = "Hello World", .free = false },
.{ .expected = "~", .input = "%7E", .free = true },
.{ .expected = "~", .input = "%7e", .free = true },
.{ .expected = "Hello~World", .input = "Hello%7eWorld", .free = true },
.{ .expected = "Hello World", .input = "Hello++World", .free = true },
};
for (cases) |case| {
const value = try unescape(allocator, case.input);
defer if (case.free) {
allocator.free(value);
};
try testing.expectEqualStrings(case.expected, value);
}
try testing.expectError(error.EscapeError, unescape(undefined, "%"));
try testing.expectError(error.EscapeError, unescape(undefined, "%a"));
try testing.expectError(error.EscapeError, unescape(undefined, "%1"));
try testing.expectError(error.EscapeError, unescape(undefined, "123%45%6"));
try testing.expectError(error.EscapeError, unescape(undefined, "%zzzzz"));
try testing.expectError(error.EscapeError, unescape(undefined, "%0\xff"));
}
test "url.Query: parseQuery" {
try testParseQuery(.{}, "");
try testParseQuery(.{}, "&");
try testParseQuery(.{ .a = [_][]const u8{"b"} }, "a=b");
try testParseQuery(.{ .hello = [_][]const u8{"world"} }, "hello=world");
try testParseQuery(.{ .hello = [_][]const u8{ "world", "all" } }, "hello=world&hello=all");
try testParseQuery(.{
.a = [_][]const u8{"b"},
.b = [_][]const u8{"c"},
}, "a=b&b=c");
try testParseQuery(.{ .a = [_][]const u8{""} }, "a");
try testParseQuery(.{ .a = [_][]const u8{ "", "", "" } }, "a&a&a");
try testParseQuery(.{ .abc = [_][]const u8{""} }, "abc");
try testParseQuery(.{
.abc = [_][]const u8{""},
.dde = [_][]const u8{ "", "" },
}, "abc&dde&dde");
try testParseQuery(.{
.@"power is >" = [_][]const u8{"9,000?"},
}, "power%20is%20%3E=9%2C000%3F");
}
test "url.Query.Values: get/first/count" {
var values = Values.init(testing.allocator);
defer values.deinit();
{
// empty
try testing.expectEqual(0, values.count());
try testing.expectEqual(0, values.get("").len);
try testing.expectEqualStrings("", values.first(""));
try testing.expectEqual(0, values.get("key").len);
try testing.expectEqualStrings("", values.first("key"));
}
{
// add 1 value => key
try values.appendOwned("key", "value");
try testing.expectEqual(1, values.count());
try testing.expectEqual(1, values.get("key").len);
try testing.expectEqualSlices(
[]const u8,
&.{"value"},
values.get("key"),
);
try testing.expectEqualStrings("value", values.first("key"));
}
{
// add another value for the same key
try values.appendOwned("key", "another");
try testing.expectEqual(1, values.count());
try testing.expectEqual(2, values.get("key").len);
try testing.expectEqualSlices(
[]const u8,
&.{ "value", "another" },
values.get("key"),
);
try testing.expectEqualStrings("value", values.first("key"));
}
{
// add a new key (and value)
try values.appendOwned("over", "9000!");
try testing.expectEqual(2, values.count());
try testing.expectEqual(2, values.get("key").len);
try testing.expectEqual(1, values.get("over").len);
try testing.expectEqualSlices(
[]const u8,
&.{"9000!"},
values.get("over"),
);
try testing.expectEqualStrings("9000!", values.first("over"));
}
}
test "url.Query.Values: encode" {
var values = try parseQuery(
testing.allocator,
"hello=world&i%20will%20not%20fear=%3E%3E&a=b&a=c",
);
defer values.deinit();
var buf: std.ArrayListUnmanaged(u8) = .{};
defer buf.deinit(testing.allocator);
try values.encode(buf.writer(testing.allocator));
try testing.expectEqualStrings(
"hello=world&i%20will%20not%20fear=%3E%3E&a=b&a=c",
buf.items,
);
}
fn testParseQuery(expected: anytype, query: []const u8) !void {
var values = try parseQuery(testing.allocator, query);
defer values.deinit();
var count: usize = 0;
inline for (@typeInfo(@TypeOf(expected)).@"struct".fields) |f| {
const actual = values.get(f.name);
const expect = @field(expected, f.name);
try testing.expectEqual(expect.len, actual.len);
for (expect, actual) |e, a| {
try testing.expectEqualStrings(e, a);
}
count += 1;
}
try testing.expectEqual(count, values.count());
}

279
src/browser/url/url.zig Normal file
View File

@@ -0,0 +1,279 @@
// 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 SessionState = @import("../env.zig").SessionState;
const query = @import("query.zig");
pub const Interfaces = .{
URL,
URLSearchParams,
};
// https://url.spec.whatwg.org/#url
//
// TODO we could avoid many of these getter string allocatoration in two differents
// way:
//
// 1. We can eventually get the slice of scheme *with* the following char in
// the underlying string. But I don't know if it's possible and how to do that.
// I mean, if the rawuri contains `https://foo.bar`, uri.scheme is a slice
// containing only `https`. I want `https:` so, in theory, I don't need to
// 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
// parser including the characters we want for the web API.
pub const URL = struct {
uri: std.Uri,
search_params: URLSearchParams,
pub fn constructor(
url: []const u8,
base: ?[]const u8,
state: *SessionState,
) !URL {
const arena = state.arena;
const raw = try std.mem.concat(arena, u8, &[_][]const u8{ url, base orelse "" });
const uri = std.Uri.parse(raw) catch return error.TypeError;
return init(arena, uri);
}
pub fn init(arena: std.mem.Allocator, uri: std.Uri) !URL {
return .{
.uri = uri,
.search_params = try URLSearchParams.init(
arena,
uriComponentNullStr(uri.query),
),
};
}
pub fn get_origin(self: *URL, state: *SessionState) ![]const u8 {
var buf = std.ArrayList(u8).init(state.arena);
try self.uri.writeToStream(.{
.scheme = true,
.authentication = false,
.authority = true,
.path = false,
.query = false,
.fragment = false,
}, buf.writer());
return buf.items;
}
// get_href returns the URL by writing all its components.
// The query is replaced by a dump of search params.
//
pub fn get_href(self: *URL, state: *SessionState) ![]const u8 {
const arena = state.arena;
// retrieve the query search from search_params.
const cur = self.uri.query;
defer self.uri.query = cur;
var q = std.ArrayList(u8).init(arena);
try self.search_params.values.encode(q.writer());
self.uri.query = .{ .percent_encoded = q.items };
return try self.toString(arena);
}
// format the url with all its components.
pub fn toString(self: *URL, arena: std.mem.Allocator) ![]const u8 {
var buf = std.ArrayList(u8).init(arena);
try self.uri.writeToStream(.{
.scheme = true,
.authentication = true,
.authority = true,
.path = uriComponentNullStr(self.uri.path).len > 0,
.query = uriComponentNullStr(self.uri.query).len > 0,
.fragment = uriComponentNullStr(self.uri.fragment).len > 0,
}, buf.writer());
return buf.items;
}
pub fn get_protocol(self: *URL, state: *SessionState) ![]const u8 {
return try std.mem.concat(state.arena, u8, &[_][]const u8{ self.uri.scheme, ":" });
}
pub fn get_username(self: *URL) []const u8 {
return uriComponentNullStr(self.uri.user);
}
pub fn get_password(self: *URL) []const u8 {
return uriComponentNullStr(self.uri.password);
}
pub fn get_host(self: *URL, state: *SessionState) ![]const u8 {
var buf = std.ArrayList(u8).init(state.arena);
try self.uri.writeToStream(.{
.scheme = false,
.authentication = false,
.authority = true,
.path = false,
.query = false,
.fragment = false,
}, buf.writer());
return buf.items;
}
pub fn get_hostname(self: *URL) []const u8 {
return uriComponentNullStr(self.uri.host);
}
pub fn get_port(self: *URL, state: *SessionState) ![]const u8 {
const arena = state.arena;
if (self.uri.port == null) return try arena.dupe(u8, "");
var buf = std.ArrayList(u8).init(arena);
try std.fmt.formatInt(self.uri.port.?, 10, .lower, .{}, buf.writer());
return buf.items;
}
pub fn get_pathname(self: *URL) []const u8 {
if (uriComponentStr(self.uri.path).len == 0) return "/";
return uriComponentStr(self.uri.path);
}
pub fn get_search(self: *URL, state: *SessionState) ![]const u8 {
const arena = state.arena;
if (self.search_params.get_size() == 0) return try arena.dupe(u8, "");
var buf: std.ArrayListUnmanaged(u8) = .{};
try buf.append(arena, '?');
try self.search_params.values.encode(buf.writer(arena));
return buf.items;
}
pub fn get_hash(self: *URL, state: *SessionState) ![]const u8 {
const arena = state.arena;
if (self.uri.fragment == null) return try arena.dupe(u8, "");
return try std.mem.concat(arena, u8, &[_][]const u8{ "#", uriComponentNullStr(self.uri.fragment) });
}
pub fn get_searchParams(self: *URL) *URLSearchParams {
return &self.search_params;
}
pub fn _toJSON(self: *URL, state: *SessionState) ![]const u8 {
return try self.get_href(state);
}
};
// uriComponentNullStr converts an optional std.Uri.Component to string value.
// The string value can be undecoded.
fn uriComponentNullStr(c: ?std.Uri.Component) []const u8 {
if (c == null) return "";
return uriComponentStr(c.?);
}
fn uriComponentStr(c: std.Uri.Component) []const u8 {
return switch (c) {
.raw => |v| v,
.percent_encoded => |v| v,
};
}
// https://url.spec.whatwg.org/#interface-urlsearchparams
// TODO array like
pub const URLSearchParams = struct {
values: query.Values,
pub fn constructor(qs: ?[]const u8, state: *SessionState) !URLSearchParams {
return init(state.arena, qs);
}
pub fn init(arena: std.mem.Allocator, qs: ?[]const u8) !URLSearchParams {
return .{
.values = try query.parseQuery(arena, qs orelse ""),
};
}
pub fn get_size(self: *URLSearchParams) u32 {
return @intCast(self.values.count());
}
pub fn _append(self: *URLSearchParams, name: []const u8, value: []const u8) !void {
try self.values.append(name, value);
}
pub fn _delete(self: *URLSearchParams, name: []const u8, value: ?[]const u8) !void {
if (value) |v| return self.values.deleteValue(name, v);
self.values.delete(name);
}
pub fn _get(self: *URLSearchParams, name: []const u8) ?[]const u8 {
return self.values.first(name);
}
// TODO return generates an error: caught unexpected error 'TypeLookup'
// pub fn _getAll(self: *URLSearchParams, name: []const u8) [][]const u8 {
// try self.values.get(name);
// }
// TODO
pub fn _sort(_: *URLSearchParams) void {}
};
const testing = @import("../../testing.zig");
test "Browser.URL" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "var url = new URL('https://foo.bar/path?query#fragment')", "undefined" },
.{ "url.origin", "https://foo.bar" },
.{ "url.href", "https://foo.bar/path?query#fragment" },
.{ "url.protocol", "https:" },
.{ "url.username", "" },
.{ "url.password", "" },
.{ "url.host", "foo.bar" },
.{ "url.hostname", "foo.bar" },
.{ "url.port", "" },
.{ "url.pathname", "/path" },
.{ "url.search", "?query" },
.{ "url.hash", "#fragment" },
.{ "url.searchParams.get('query')", "" },
}, .{});
try runner.testCases(&.{
.{ "var url = new URL('https://foo.bar/path?a=~&b=%7E#fragment')", "undefined" },
.{ "url.searchParams.get('a')", "~" },
.{ "url.searchParams.get('b')", "~" },
.{ "url.searchParams.append('c', 'foo')", "undefined" },
.{ "url.searchParams.get('c')", "foo" },
.{ "url.searchParams.size", "3" },
// search is dynamic
.{ "url.search", "?a=%7E&b=%7E&c=foo" },
// href is dynamic
.{ "url.href", "https://foo.bar/path?a=%7E&b=%7E&c=foo#fragment" },
.{ "url.searchParams.delete('c', 'foo')", "undefined" },
.{ "url.searchParams.get('c')", "" },
.{ "url.searchParams.delete('a')", "undefined" },
.{ "url.searchParams.get('a')", "" },
}, .{});
}

View File

@@ -18,19 +18,19 @@
const std = @import("std");
const jsruntime = @import("jsruntime");
const Callback = jsruntime.Callback;
const Env = @import("../env.zig").Env;
const Callback = Env.Callback;
const EventTarget = @import("../dom/event_target.zig").EventTarget;
const EventHandler = @import("../events/event.zig").EventHandler;
const parser = @import("netsurf");
const parser = @import("../netsurf.zig");
const SessionState = @import("../env.zig").SessionState;
const log = std.log.scoped(.xhr);
pub const XMLHttpRequestEventTarget = struct {
pub const prototype = *EventTarget;
pub const mem_guarantied = true;
// Extend libdom event target for pure zig struct.
base: parser.EventTargetTBase = parser.EventTargetTBase{},
@@ -48,25 +48,25 @@ pub const XMLHttpRequestEventTarget = struct {
typ: []const u8,
cbk: Callback,
) !void {
const target = @as(*parser.EventTarget, @ptrCast(self));
const eh = try EventHandler.init(alloc, try cbk.withThis(target));
try parser.eventTargetAddEventListener(
@as(*parser.EventTarget, @ptrCast(self)),
alloc,
target,
typ,
EventHandler,
.{ .cbk = cbk },
&eh.node,
false,
);
}
fn unregister(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator, typ: []const u8, cbk: Callback) !void {
fn unregister(self: *XMLHttpRequestEventTarget, typ: []const u8, cbk_id: usize) !void {
const et = @as(*parser.EventTarget, @ptrCast(self));
// check if event target has already this listener
const lst = try parser.eventTargetHasListener(et, typ, false, cbk.id());
const lst = try parser.eventTargetHasListener(et, typ, false, cbk_id);
if (lst == null) {
return;
}
// remove listener
try parser.eventTargetRemoveEventListener(et, alloc, typ, lst.?, false);
try parser.eventTargetRemoveEventListener(et, typ, lst.?, false);
}
pub fn get_onloadstart(self: *XMLHttpRequestEventTarget) ?Callback {
@@ -88,39 +88,40 @@ pub const XMLHttpRequestEventTarget = struct {
return self.onloadend_cbk;
}
pub fn set_onloadstart(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator, handler: Callback) !void {
if (self.onloadstart_cbk) |cbk| try self.unregister(alloc, "loadstart", cbk);
try self.register(alloc, "loadstart", handler);
pub fn set_onloadstart(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
if (self.onloadstart_cbk) |cbk| try self.unregister("loadstart", cbk.id);
try self.register(state.arena, "loadstart", handler);
self.onloadstart_cbk = handler;
}
pub fn set_onprogress(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator, handler: Callback) !void {
if (self.onprogress_cbk) |cbk| try self.unregister(alloc, "progress", cbk);
try self.register(alloc, "progress", handler);
pub fn set_onprogress(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
if (self.onprogress_cbk) |cbk| try self.unregister("progress", cbk.id);
try self.register(state.arena, "progress", handler);
self.onprogress_cbk = handler;
}
pub fn set_onabort(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator, handler: Callback) !void {
if (self.onabort_cbk) |cbk| try self.unregister(alloc, "abort", cbk);
try self.register(alloc, "abort", handler);
pub fn set_onabort(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
if (self.onabort_cbk) |cbk| try self.unregister("abort", cbk.id);
try self.register(state.arena, "abort", handler);
self.onabort_cbk = handler;
}
pub fn set_onload(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator, handler: Callback) !void {
if (self.onload_cbk) |cbk| try self.unregister(alloc, "load", cbk);
try self.register(alloc, "load", handler);
pub fn set_onload(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
if (self.onload_cbk) |cbk| try self.unregister("load", cbk.id);
try self.register(state.arena, "load", handler);
self.onload_cbk = handler;
}
pub fn set_ontimeout(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator, handler: Callback) !void {
if (self.ontimeout_cbk) |cbk| try self.unregister(alloc, "timeout", cbk);
try self.register(alloc, "timeout", handler);
pub fn set_ontimeout(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
if (self.ontimeout_cbk) |cbk| try self.unregister("timeout", cbk.id);
try self.register(state.arena, "timeout", handler);
self.ontimeout_cbk = handler;
}
pub fn set_onloadend(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator, handler: Callback) !void {
if (self.onloadend_cbk) |cbk| try self.unregister(alloc, "loadend", cbk);
try self.register(alloc, "loadend", handler);
pub fn set_onloadend(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
if (self.onloadend_cbk) |cbk| try self.unregister("loadend", cbk.id);
try self.register(state.arena, "loadend", handler);
self.onloadend_cbk = handler;
}
pub fn deinit(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator) void {
parser.eventTargetRemoveAllEventListeners(@as(*parser.EventTarget, @ptrCast(self)), alloc) catch |e| {
pub fn deinit(self: *XMLHttpRequestEventTarget, state: *SessionState) void {
const arena = state.arena;
parser.eventTargetRemoveAllEventListeners(@as(*parser.EventTarget, @ptrCast(self)), arena) catch |e| {
log.err("remove all listeners: {any}", .{e});
};
}

View File

@@ -16,13 +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 jsruntime = @import("jsruntime");
const Case = jsruntime.test_utils.Case;
const checkCases = jsruntime.test_utils.checkCases;
const parser = @import("netsurf");
const parser = @import("../netsurf.zig");
const Event = @import("../events/event.zig").Event;
const DOMException = @import("../dom/exceptions.zig").DOMException;
@@ -30,7 +24,6 @@ const DOMException = @import("../dom/exceptions.zig").DOMException;
pub const ProgressEvent = struct {
pub const prototype = *Event;
pub const Exception = DOMException;
pub const mem_guarantied = true;
pub const EventInit = struct {
lengthComputable: bool = false,
@@ -59,32 +52,32 @@ pub const ProgressEvent = struct {
};
}
pub fn get_lengthComputable(self: ProgressEvent) bool {
pub fn get_lengthComputable(self: *const ProgressEvent) bool {
return self.lengthComputable;
}
pub fn get_loaded(self: ProgressEvent) u64 {
pub fn get_loaded(self: *const ProgressEvent) u64 {
return self.loaded;
}
pub fn get_total(self: ProgressEvent) u64 {
pub fn get_total(self: *const ProgressEvent) u64 {
return self.total;
}
};
pub fn testExecFn(
_: std.mem.Allocator,
js_env: *jsruntime.Env,
) anyerror!void {
var progress_event = [_]Case{
.{ .src = "let pevt = new ProgressEvent('foo');", .ex = "undefined" },
.{ .src = "pevt.loaded", .ex = "0" },
.{ .src = "pevt instanceof ProgressEvent", .ex = "true" },
.{ .src = "var nnb = 0; var eevt = null; function ccbk(event) { nnb ++; eevt = event; }", .ex = "undefined" },
.{ .src = "document.addEventListener('foo', ccbk)", .ex = "undefined" },
.{ .src = "document.dispatchEvent(pevt)", .ex = "true" },
.{ .src = "eevt.type", .ex = "foo" },
.{ .src = "eevt instanceof ProgressEvent", .ex = "true" },
};
try checkCases(js_env, &progress_event);
const testing = @import("../../testing.zig");
test "Browser.XHR.ProgressEvent" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "let pevt = new ProgressEvent('foo');", "undefined" },
.{ "pevt.loaded", "0" },
.{ "pevt instanceof ProgressEvent", "true" },
.{ "var nnb = 0; var eevt = null; function ccbk(event) { nnb ++; eevt = event; }", "undefined" },
.{ "document.addEventListener('foo', ccbk)", "undefined" },
.{ "document.dispatchEvent(pevt)", "true" },
.{ "eevt.type", "foo" },
.{ "eevt instanceof ProgressEvent", "true" },
}, .{});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
// 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 SessionState = @import("../env.zig").SessionState;
const dump = @import("../dump.zig");
const parser = @import("../netsurf.zig");
pub const Interfaces = .{
XMLSerializer,
};
// https://w3c.github.io/DOM-Parsing/#dom-xmlserializer-constructor
pub const XMLSerializer = struct {
pub fn constructor() !XMLSerializer {
return .{};
}
pub fn _serializeToString(_: *const XMLSerializer, root: *parser.Node, state: *SessionState) ![]const u8 {
var buf = std.ArrayList(u8).init(state.arena);
if (try parser.nodeType(root) == .document) {
try dump.writeHTML(@as(*parser.Document, @ptrCast(root)), buf.writer());
} else {
try dump.writeNode(root, buf.writer());
}
return buf.items;
}
};
const testing = @import("../../testing.zig");
test "Browser.XMLSerializer" {
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
defer runner.deinit();
try runner.testCases(&.{
.{ "const s = new XMLSerializer()", "undefined" },
.{ "s.serializeToString(document.getElementById('para'))", "<p id=\"para\"> And</p>" },
}, .{});
}

499
src/cdp/Node.zig Normal file
View File

@@ -0,0 +1,499 @@
// 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 parser = @import("../browser/netsurf.zig");
pub const Id = u32;
const log = std.log.scoped(.cdp_node);
const Node = @This();
id: Id,
_node: *parser.Node,
set_child_nodes_event: bool,
// Whenever we send a node to the client, we register it here for future lookup.
// We maintain a node -> id and id -> node lookup.
pub const Registry = struct {
node_id: u32,
allocator: Allocator,
arena: std.heap.ArenaAllocator,
node_pool: std.heap.MemoryPool(Node),
lookup_by_id: std.AutoHashMapUnmanaged(Id, *Node),
lookup_by_node: std.HashMapUnmanaged(*parser.Node, *Node, NodeContext, std.hash_map.default_max_load_percentage),
pub fn init(allocator: Allocator) Registry {
return .{
.node_id = 0,
.lookup_by_id = .{},
.lookup_by_node = .{},
.allocator = allocator,
.arena = std.heap.ArenaAllocator.init(allocator),
.node_pool = std.heap.MemoryPool(Node).init(allocator),
};
}
pub fn deinit(self: *Registry) void {
const allocator = self.allocator;
self.lookup_by_id.deinit(allocator);
self.lookup_by_node.deinit(allocator);
self.node_pool.deinit();
self.arena.deinit();
}
pub fn reset(self: *Registry) void {
self.lookup_by_id.clearRetainingCapacity();
self.lookup_by_node.clearRetainingCapacity();
_ = self.arena.reset(.{ .retain_with_limit = 1024 });
_ = self.node_pool.reset(.{ .retain_with_limit = 1024 });
}
pub fn register(self: *Registry, n: *parser.Node) !*Node {
const node_lookup_gop = try self.lookup_by_node.getOrPut(self.allocator, n);
if (node_lookup_gop.found_existing) {
return node_lookup_gop.value_ptr.*;
}
// on error, we're probably going to abort the entire browser context
// but, just in case, let's try to keep things tidy.
errdefer _ = self.lookup_by_node.remove(n);
const node = try self.node_pool.create();
errdefer self.node_pool.destroy(node);
const id = self.node_id;
self.node_id = id + 1;
node.* = .{
._node = n,
.id = id,
.set_child_nodes_event = false,
};
node_lookup_gop.value_ptr.* = node;
try self.lookup_by_id.putNoClobber(self.allocator, id, node);
return node;
}
};
const NodeContext = struct {
pub fn hash(_: NodeContext, n: *parser.Node) u64 {
return std.hash.Wyhash.hash(0, std.mem.asBytes(&@intFromPtr(n)));
}
pub fn eql(_: NodeContext, a: *parser.Node, b: *parser.Node) bool {
return @intFromPtr(a) == @intFromPtr(b);
}
};
// Searches are a 3 step process:
// 1 - Dom.performSearch
// 2 - Dom.getSearchResults
// 3 - Dom.discardSearchResults
//
// For a given browser context, we can have multiple active searches. I.e.
// performSearch could be called multiple times without getSearchResults or
// discardSearchResults being called. We keep these active searches in the
// browser context's node_search_list, which is a SearchList. Since we don't
// expect many active searches (mostly just 1), a list is fine to scan through.
pub const Search = struct {
name: []const u8,
node_ids: []const Id,
pub const List = struct {
registry: *Registry,
search_id: u16 = 0,
arena: std.heap.ArenaAllocator,
searches: std.ArrayListUnmanaged(Search) = .{},
pub fn init(allocator: Allocator, registry: *Registry) List {
return .{
.registry = registry,
.arena = std.heap.ArenaAllocator.init(allocator),
};
}
pub fn deinit(self: *List) void {
self.arena.deinit();
}
pub fn reset(self: *List) void {
self.search_id = 0;
self.searches = .{};
_ = self.arena.reset(.{ .retain_with_limit = 4096 });
}
pub fn create(self: *List, nodes: []const *parser.Node) !Search {
const id = self.search_id;
defer self.search_id = id +% 1;
const arena = self.arena.allocator();
const name = switch (id) {
0 => "0",
1 => "1",
2 => "2",
3 => "3",
4 => "4",
5 => "5",
6 => "6",
7 => "7",
8 => "8",
9 => "9",
else => try std.fmt.allocPrint(arena, "{d}", .{id}),
};
var registry = self.registry;
const node_ids = try arena.alloc(Id, nodes.len);
for (nodes, node_ids) |node, *node_id| {
node_id.* = (try registry.register(node)).id;
}
const search = Search{
.name = name,
.node_ids = node_ids,
};
try self.searches.append(arena, search);
return search;
}
pub fn remove(self: *List, name: []const u8) void {
for (self.searches.items, 0..) |search, i| {
if (std.mem.eql(u8, name, search.name)) {
_ = self.searches.swapRemove(i);
return;
}
}
}
pub fn get(self: *const List, name: []const u8) ?Search {
for (self.searches.items) |search| {
if (std.mem.eql(u8, name, search.name)) {
return search;
}
}
return null;
}
};
};
// Need a custom writer, because we can't just serialize the node as-is.
// Sometimes we want to serializ the node without chidren, sometimes with just
// its direct children, and sometimes the entire tree.
// (For now, we only support direct children)
pub const Writer = struct {
opts: Opts,
node: *const Node,
registry: *Registry,
pub const Opts = struct {};
pub fn jsonStringify(self: *const Writer, w: anytype) !void {
self.toJSON(w) catch |err| {
// The only error our jsonStringify method can return is
// @TypeOf(w).Error. In other words, our code can't return its own
// error, we can only return a writer error. Kinda sucks.
log.err("json stringify: {}", .{err});
return error.OutOfMemory;
};
}
fn toJSON(self: *const Writer, w: anytype) !void {
try w.beginObject();
try self.writeCommon(self.node, false, w);
{
var registry = self.registry;
const child_nodes = try parser.nodeGetChildNodes(self.node._node);
const child_count = try parser.nodeListLength(child_nodes);
var i: usize = 0;
try w.objectField("children");
try w.beginArray();
for (0..child_count) |_| {
const child = (try parser.nodeListItem(child_nodes, @intCast(i))) orelse break;
const child_node = try registry.register(child);
try w.beginObject();
try self.writeCommon(child_node, true, w);
try w.endObject();
i += 1;
}
try w.endArray();
try w.objectField("childNodeCount");
try w.write(i);
}
try w.endObject();
}
fn writeCommon(self: *const Writer, node: *const Node, include_child_count: bool, w: anytype) !void {
try w.objectField("nodeId");
try w.write(node.id);
try w.objectField("backendNodeId");
try w.write(node.id);
const n = node._node;
if (try parser.nodeParentNode(n)) |p| {
const parent_node = try self.registry.register(p);
try w.objectField("parentId");
try w.write(parent_node.id);
}
const _map = try parser.nodeGetAttributes(n);
if (_map) |map| {
const attr_count = try parser.namedNodeMapGetLength(map);
try w.objectField("attributes");
try w.beginArray();
for (0..attr_count) |i| {
const attr = try parser.namedNodeMapItem(map, @intCast(i)) orelse continue;
try w.write(try parser.attributeGetName(attr));
try w.write(try parser.attributeGetValue(attr) orelse continue);
}
try w.endArray();
}
try w.objectField("nodeType");
try w.write(@intFromEnum(try parser.nodeType(n)));
try w.objectField("nodeName");
try w.write(try parser.nodeName(n));
try w.objectField("localName");
try w.write(try parser.nodeLocalName(n));
try w.objectField("nodeValue");
try w.write((try parser.nodeValue(n)) orelse "");
if (include_child_count) {
try w.objectField("childNodeCount");
const child_nodes = try parser.nodeGetChildNodes(n);
try w.write(try parser.nodeListLength(child_nodes));
}
try w.objectField("documentURL");
try w.write(null);
try w.objectField("baseURL");
try w.write(null);
try w.objectField("xmlVersion");
try w.write("");
try w.objectField("compatibilityMode");
try w.write("NoQuirksMode");
try w.objectField("isScrollable");
try w.write(false);
}
};
const testing = @import("testing.zig");
test "cdp Node: Registry register" {
var registry = Registry.init(testing.allocator);
defer registry.deinit();
try testing.expectEqual(0, registry.lookup_by_id.count());
try testing.expectEqual(0, registry.lookup_by_node.count());
var doc = try testing.Document.init("<a id=a1>link1</a><div id=d2><p>other</p></div>");
defer doc.deinit();
{
const n = (try doc.querySelector("#a1")).?;
const node = try registry.register(n);
const n1b = registry.lookup_by_id.get(0).?;
const n1c = registry.lookup_by_node.get(node._node).?;
try testing.expectEqual(node, n1b);
try testing.expectEqual(node, n1c);
try testing.expectEqual(0, node.id);
try testing.expectEqual(n, node._node);
}
{
const n = (try doc.querySelector("p")).?;
const node = try registry.register(n);
const n1b = registry.lookup_by_id.get(1).?;
const n1c = registry.lookup_by_node.get(node._node).?;
try testing.expectEqual(node, n1b);
try testing.expectEqual(node, n1c);
try testing.expectEqual(1, node.id);
try testing.expectEqual(n, node._node);
}
}
test "cdp Node: search list" {
var registry = Registry.init(testing.allocator);
defer registry.deinit();
var search_list = Search.List.init(testing.allocator, &registry);
defer search_list.deinit();
{
// empty search list, noops
search_list.remove("0");
try testing.expectEqual(null, search_list.get("0"));
}
{
// empty nodes
const s1 = try search_list.create(&.{});
try testing.expectEqual("0", s1.name);
try testing.expectEqual(0, s1.node_ids.len);
const s2 = search_list.get("0").?;
try testing.expectEqual("0", s2.name);
try testing.expectEqual(0, s2.node_ids.len);
search_list.remove("0");
try testing.expectEqual(null, search_list.get("0"));
}
{
var doc = try testing.Document.init("<a id=a1></a><a id=a2></a>");
defer doc.deinit();
const s1 = try search_list.create(try doc.querySelectorAll("a"));
try testing.expectEqual("1", s1.name);
try testing.expectEqualSlices(u32, &.{ 0, 1 }, s1.node_ids);
try testing.expectEqual(2, registry.lookup_by_id.count());
try testing.expectEqual(2, registry.lookup_by_node.count());
const s2 = try search_list.create(try doc.querySelectorAll("#a1"));
try testing.expectEqual("2", s2.name);
try testing.expectEqualSlices(u32, &.{0}, s2.node_ids);
const s3 = try search_list.create(try doc.querySelectorAll("#a2"));
try testing.expectEqual("3", s3.name);
try testing.expectEqualSlices(u32, &.{1}, s3.node_ids);
try testing.expectEqual(2, registry.lookup_by_id.count());
try testing.expectEqual(2, registry.lookup_by_node.count());
}
}
test "cdp Node: Writer" {
var registry = Registry.init(testing.allocator);
defer registry.deinit();
var doc = try testing.Document.init("<a id=a1></a><a id=a2></a>");
defer doc.deinit();
{
const node = try registry.register(doc.asNode());
const json = try std.json.stringifyAlloc(testing.allocator, Writer{
.node = node,
.opts = .{},
.registry = &registry,
}, .{});
defer testing.allocator.free(json);
try testing.expectJson(.{
.nodeId = 0,
.backendNodeId = 0,
.nodeType = 9,
.nodeName = "#document",
.localName = "",
.nodeValue = "",
.documentURL = null,
.baseURL = null,
.xmlVersion = "",
.isScrollable = false,
.compatibilityMode = "NoQuirksMode",
.childNodeCount = 1,
.children = &.{.{
.nodeId = 1,
.backendNodeId = 1,
.nodeType = 1,
.nodeName = "HTML",
.localName = "html",
.nodeValue = "",
.childNodeCount = 2,
.documentURL = null,
.baseURL = null,
.xmlVersion = "",
.compatibilityMode = "NoQuirksMode",
.isScrollable = false,
}},
}, json);
}
{
const node = registry.lookup_by_id.get(1).?;
const json = try std.json.stringifyAlloc(testing.allocator, Writer{
.node = node,
.opts = .{},
.registry = &registry,
}, .{});
defer testing.allocator.free(json);
try testing.expectJson(.{
.nodeId = 1,
.backendNodeId = 1,
.nodeType = 1,
.nodeName = "HTML",
.localName = "html",
.nodeValue = "",
.childNodeCount = 2,
.documentURL = null,
.baseURL = null,
.xmlVersion = "",
.compatibilityMode = "NoQuirksMode",
.isScrollable = false,
.children = &.{ .{
.nodeId = 2,
.backendNodeId = 2,
.nodeType = 1,
.nodeName = "HEAD",
.localName = "head",
.nodeValue = "",
.childNodeCount = 0,
.documentURL = null,
.baseURL = null,
.xmlVersion = "",
.compatibilityMode = "NoQuirksMode",
.isScrollable = false,
.parentId = 1,
}, .{
.nodeId = 3,
.backendNodeId = 3,
.nodeType = 1,
.nodeName = "BODY",
.localName = "body",
.nodeValue = "",
.childNodeCount = 2,
.documentURL = null,
.baseURL = null,
.xmlVersion = "",
.compatibilityMode = "NoQuirksMode",
.isScrollable = false,
.parentId = 1,
} },
}, json);
}
}

737
src/cdp/cdp.zig Normal file
View File

@@ -0,0 +1,737 @@
// 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 json = std.json;
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/browser.zig").Session;
const Page = @import("../browser/browser.zig").Page;
const Inspector = @import("../browser/env.zig").Env.Inspector;
const Incrementing = @import("../id.zig").Incrementing;
const Notification = @import("../notification.zig").Notification;
const log = std.log.scoped(.cdp);
pub const URL_BASE = "chrome://newtab/";
pub const LOADER_ID = "LOADERID24DD2FD56CF1EF33C965C79C";
pub const CDP = CDPT(struct {
const Client = *@import("../server.zig").Client;
});
const SessionIdGen = Incrementing(u32, "SID");
const TargetIdGen = Incrementing(u32, "TID");
const LoaderIdGen = Incrementing(u32, "LID");
const BrowserContextIdGen = Incrementing(u32, "BID");
// Generic so that we can inject mocks into it.
pub fn CDPT(comptime TypeProvider: type) type {
return struct {
// Used for sending message to the client and closing on error
client: TypeProvider.Client,
allocator: Allocator,
// The active browser
browser: Browser,
// when true, any target creation must be attached.
target_auto_attach: bool = false,
target_id_gen: TargetIdGen = .{},
loader_id_gen: LoaderIdGen = .{},
session_id_gen: SessionIdGen = .{},
browser_context_id_gen: BrowserContextIdGen = .{},
browser_context: ?BrowserContext(Self),
// Re-used arena for processing a message. We're assuming that we're getting
// 1 message at a time.
message_arena: std.heap.ArenaAllocator,
const Self = @This();
pub fn init(app: *App, client: TypeProvider.Client) !Self {
const allocator = app.allocator;
const browser = try Browser.init(app);
errdefer browser.deinit();
return .{
.client = client,
.browser = browser,
.allocator = allocator,
.browser_context = null,
.message_arena = std.heap.ArenaAllocator.init(allocator),
};
}
pub fn deinit(self: *Self) void {
if (self.browser_context) |*bc| {
bc.deinit();
}
self.browser.deinit();
self.message_arena.deinit();
}
pub fn handleMessage(self: *Self, msg: []const u8) bool {
// if there's an error, it's already been logged
self.processMessage(msg) catch return false;
return true;
}
pub fn processMessage(self: *Self, msg: []const u8) !void {
const arena = &self.message_arena;
defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
return self.dispatch(arena.allocator(), self, msg);
}
// Called from above, in processMessage which handles client messages
// but can also be called internally. For example, Target.sendMessageToTarget
// calls back into dispatch to capture the response.
pub fn dispatch(self: *Self, arena: Allocator, sender: anytype, str: []const u8) !void {
const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{
.ignore_unknown_fields = true,
}) catch return error.InvalidJSON;
var command = Command(Self, @TypeOf(sender)){
.input = .{
.json = str,
.id = input.id,
.action = "",
.params = input.params,
.session_id = input.sessionId,
},
.cdp = self,
.arena = arena,
.sender = sender,
.browser_context = if (self.browser_context) |*bc| bc else null,
};
// See dispatchStartupCommand for more info on this.
var is_startup = false;
if (input.sessionId) |input_session_id| {
if (std.mem.eql(u8, input_session_id, "STARTUP")) {
is_startup = true;
} else if (self.isValidSessionId(input_session_id) == false) {
return command.sendError(-32001, "Unknown sessionId");
}
}
if (is_startup) {
dispatchStartupCommand(&command) catch |err| {
command.sendError(-31999, @errorName(err)) catch {};
return err;
};
} else {
dispatchCommand(&command, input.method) catch |err| {
command.sendError(-31998, @errorName(err)) catch {};
return err;
};
}
}
// A CDP session isn't 100% fully driven by the driver. There's are
// independent actions that the browser is expected to take. For example
// Puppeteer expects the browser to startup a tab and thus have existing
// targets.
// To this end, we create a [very] dummy BrowserContext, Target and
// Session. There isn't actually a BrowserContext, just a special id.
// When messages are received with the "STARTUP" sessionId, we do
// "special" handling - the bare minimum we need to do until the driver
// switches to a real BrowserContext.
// (I can imagine this logic will become driver-specific)
fn dispatchStartupCommand(command: anytype) !void {
return command.sendResult(null, .{});
}
fn dispatchCommand(command: anytype, method: []const u8) !void {
const domain = blk: {
const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse {
return error.InvalidMethod;
};
command.input.action = method[i + 1 ..];
break :blk method[0..i];
};
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),
else => {},
},
4 => switch (@as(u32, @bitCast(domain[0..4].*))) {
asUint("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),
else => {},
},
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
asUint("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),
else => {},
},
8 => switch (@as(u64, @bitCast(domain[0..8].*))) {
asUint("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),
else => {},
},
11 => switch (@as(u88, @bitCast(domain[0..11].*))) {
asUint("Performance") => return @import("domains/performance.zig").processMessage(command),
else => {},
},
else => {},
}
return error.UnknownDomain;
}
fn isValidSessionId(self: *const Self, input_session_id: []const u8) bool {
const browser_context = &(self.browser_context orelse return false);
const session_id = browser_context.session_id orelse return false;
return std.mem.eql(u8, session_id, input_session_id);
}
pub fn createBrowserContext(self: *Self) ![]const u8 {
if (self.browser_context != null) {
return error.AlreadyExists;
}
const id = self.browser_context_id_gen.next();
self.browser_context = @as(BrowserContext(Self), undefined);
const browser_context = &self.browser_context.?;
try BrowserContext(Self).init(browser_context, id, self);
return id;
}
pub fn disposeBrowserContext(self: *Self, browser_context_id: []const u8) bool {
const bc = &(self.browser_context orelse return false);
if (std.mem.eql(u8, bc.id, browser_context_id) == false) {
return false;
}
bc.deinit();
self.browser.closeSession();
self.browser_context = null;
return true;
}
const SendEventOpts = struct {
session_id: ?[]const u8 = null,
};
pub fn sendEvent(self: *Self, method: []const u8, p: anytype, opts: SendEventOpts) !void {
return self.sendJSON(.{
.method = method,
.params = if (comptime @typeInfo(@TypeOf(p)) == .null) struct {}{} else p,
.sessionId = opts.session_id,
});
}
fn sendJSON(self: *Self, message: anytype) !void {
return self.client.sendJSON(message, .{
.emit_null_optional_fields = false,
});
}
};
}
pub fn BrowserContext(comptime CDP_T: type) type {
const Node = @import("Node.zig");
return struct {
id: []const u8,
cdp: *CDP_T,
// Represents the browser session. There is no equivalent in CDP. For
// all intents and purpose, from CDP's point of view our Browser and
// our Session more or less maps to a BrowserContext. THIS HAS ZERO
// RELATION TO SESSION_ID
session: *Session,
// Points to the session arena
arena: Allocator,
// Maps to our Page. (There are other types of targets, but we only
// deal with "pages" for now). Since we only allow 1 open page at a
// time, we only have 1 target_id.
target_id: ?[]const u8,
// The CDP session_id. After the target/page is created, the client
// "attaches" to it (either explicitly or automatically). We return a
// "sessionId" which identifies this link. `sessionId` is the how
// the CDP client informs us what it's trying to manipulate. Because we
// only support 1 BrowserContext at a time, and 1 page at a time, this
// is all pretty straightforward, but it still needs to be enforced, i.e.
// if we get a request with a sessionId that doesn't match the current one
// we should reject it.
session_id: ?[]const u8,
loader_id: []const u8,
security_origin: []const u8,
page_life_cycle_events: bool,
secure_context_type: []const u8,
node_registry: Node.Registry,
node_search_list: Node.Search.List,
inspector: Inspector,
isolated_world: ?IsolatedWorld,
const Self = @This();
fn init(self: *Self, id: []const u8, cdp: *CDP_T) !void {
const allocator = cdp.allocator;
const session = try cdp.browser.newSession();
const arena = session.arena.allocator();
const inspector = try cdp.browser.env.newInspector(arena, self);
var registry = Node.Registry.init(allocator);
errdefer registry.deinit();
self.* = .{
.id = id,
.cdp = cdp,
.arena = arena,
.target_id = null,
.session_id = null,
.session = session,
.security_origin = URL_BASE,
.secure_context_type = "Secure", // TODO = enum
.loader_id = LOADER_ID,
.page_life_cycle_events = false, // TODO; Target based value
.node_registry = registry,
.node_search_list = undefined,
.isolated_world = null,
.inspector = inspector,
};
self.node_search_list = Node.Search.List.init(allocator, &self.node_registry);
errdefer self.deinit();
try cdp.browser.notification.register(.page_remove, self, onPageRemove);
try cdp.browser.notification.register(.page_created, self, onPageCreated);
try cdp.browser.notification.register(.page_navigate, self, onPageNavigate);
try cdp.browser.notification.register(.page_navigated, self, onPageNavigated);
}
pub fn deinit(self: *Self) void {
self.inspector.deinit();
// If the session has a page, we need to clear it first. The page
// context is always nested inside of the isolated world context,
// so we need to shutdown the page one first.
self.cdp.browser.closeSession();
if (self.isolated_world) |*world| {
world.deinit();
}
self.node_registry.deinit();
self.node_search_list.deinit();
self.cdp.browser.notification.unregisterAll(self);
}
pub fn reset(self: *Self) void {
self.node_registry.reset();
self.node_search_list.reset();
}
pub fn createIsolatedWorld(self: *Self, world_name: []const u8, grant_universal_access: bool) !*IsolatedWorld {
if (self.isolated_world != null) {
return error.CurrentlyOnly1IsolatedWorldSupported;
}
var executor = try self.cdp.browser.env.newExecutor();
errdefer executor.deinit();
self.isolated_world = .{
.name = try self.arena.dupe(u8, world_name),
.scope = null,
.executor = executor,
.grant_universal_access = grant_universal_access,
};
return &self.isolated_world.?;
}
pub fn nodeWriter(self: *Self, node: *const Node, opts: Node.Writer.Opts) Node.Writer {
return .{
.node = node,
.opts = opts,
.registry = &self.node_registry,
};
}
pub fn getURL(self: *const Self) ?[]const u8 {
const page = self.session.currentPage() orelse return null;
const raw_url = page.url.raw;
return if (raw_url.len == 0) null else raw_url;
}
pub fn onPageRemove(ctx: *anyopaque, _: Notification.PageRemove) !void {
const self: *Self = @alignCast(@ptrCast(ctx));
return @import("domains/page.zig").pageRemove(self);
}
pub fn onPageCreated(ctx: *anyopaque, page: *Page) !void {
const self: *Self = @alignCast(@ptrCast(ctx));
return @import("domains/page.zig").pageCreated(self, page);
}
pub fn onPageNavigate(ctx: *anyopaque, data: *const Notification.PageNavigate) !void {
const self: *Self = @alignCast(@ptrCast(ctx));
return @import("domains/page.zig").pageNavigate(self, data);
}
pub fn onPageNavigated(ctx: *anyopaque, data: *const Notification.PageNavigated) !void {
const self: *Self = @alignCast(@ptrCast(ctx));
return @import("domains/page.zig").pageNavigated(self, data);
}
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, msg: []const u8) void {
if (std.log.defaultLogEnabled(.debug)) {
// msg should be {"id":<id>,...
std.debug.assert(std.mem.startsWith(u8, msg, "{\"id\":"));
const id_end = std.mem.indexOfScalar(u8, msg, ',') orelse {
log.warn("invalid inspector response message: {s}", .{msg});
return;
};
const id = msg[6..id_end];
log.debug("Res (inspector) > id {s}", .{id});
}
sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg) catch |err| {
log.err("Failed to send inspector response: {any}", .{err});
};
}
pub fn onInspectorEvent(ctx: *anyopaque, msg: []const u8) void {
if (std.log.defaultLogEnabled(.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.warn("invalid inspector event message: {s}", .{msg});
return;
};
const method = msg[10..method_end];
log.debug("Event (inspector) > method {s}", .{method});
}
sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg) catch |err| {
log.err("Failed to send inspector event: {any}", .{err});
};
}
// 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, 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?
return;
};
const cdp = self.cdp;
var arena = std.heap.ArenaAllocator.init(cdp.allocator);
errdefer arena.deinit();
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("Failed to expand inspector buffer: {any}", .{err});
return;
};
// reserve 10 bytes for websocket header
buf.appendSliceAssumeCapacity(&.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 });
// -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);
}
};
}
/// see: https://chromium.googlesource.com/chromium/src/+/master/third_party/blink/renderer/bindings/core/v8/V8BindingDesign.md#world
/// The current understanding. An isolated world lives in the same isolate, but a separated context.
/// Clients create this to be able to create variables and run code without interfering with the
/// normal namespace and values of the webpage. Similar to the main context we need to pretend to recreate it after
/// a executionContextsCleared event which happens when navigating to a new page. A client can have a command be executed
/// in the isolated world by using its Context ID or the worldName.
/// grantUniveralAccess Indecated whether the isolated world can reference objects like the DOM or other JS Objects.
/// An isolated world has it's own instance of globals like Window.
/// Generally the client needs to resolve a node into the isolated world to be able to work with it.
/// An object id is unique across all contexts, different object ids can refer to the same Node in different contexts.
const IsolatedWorld = struct {
name: []const u8,
scope: ?*Env.Scope,
executor: Env.Executor,
grant_universal_access: bool,
pub fn deinit(self: *IsolatedWorld) void {
self.executor.deinit();
self.scope = null;
}
pub fn removeContext(self: *IsolatedWorld) !void {
if (self.scope == null) return error.NoIsolatedContextToRemove;
self.executor.endScope();
self.scope = null;
}
// The isolate world must share at least some of the state with the related page, specifically the DocumentHTML
// (assuming grantUniveralAccess will be set to True!).
// We just created the world and the page. The page's state lives in the session, but is update on navigation.
// 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.scope != null) return error.Only1IsolatedContextSupported;
self.scope = try self.executor.startScope(&page.window, &page.state, {}, false);
}
};
// This is a generic because when we send a result we have two different
// behaviors. Normally, we're sending the result to the client. But in some cases
// we want to capture the result. So we want the command.sendResult to be
// generic.
pub fn Command(comptime CDP_T: type, comptime Sender: type) type {
return struct {
// A misc arena that can be used for any allocation for processing
// the message
arena: Allocator,
// reference to our CDP instance
cdp: *CDP_T,
// The browser context this command targets
browser_context: ?*BrowserContext(CDP_T),
// The command input (the id, optional session_id, params, ...)
input: Input,
// In most cases, Sender is going to be cdp itself. We'll call
// sender.sendJSON() and CDP will send it to the client. But some
// comamnds are dispatched internally, in which cases the Sender will
// be code to capture the data that we were "sending".
sender: Sender,
const Self = @This();
pub fn params(self: *const Self, comptime T: type) !?T {
if (self.input.params) |p| {
return try json.parseFromSliceLeaky(
T,
self.arena,
p.raw,
.{ .ignore_unknown_fields = true },
);
}
return null;
}
pub fn createBrowserContext(self: *Self) !*BrowserContext(CDP_T) {
_ = try self.cdp.createBrowserContext();
self.browser_context = &(self.cdp.browser_context.?);
return self.browser_context.?;
}
const SendResultOpts = struct {
include_session_id: bool = true,
};
pub fn sendResult(self: *Self, result: anytype, opts: SendResultOpts) !void {
return self.sender.sendJSON(.{
.id = self.input.id,
.result = if (comptime @typeInfo(@TypeOf(result)) == .null) struct {}{} else result,
.sessionId = if (opts.include_session_id) self.input.session_id else null,
});
}
const SendEventOpts = struct {
session_id: ?[]const u8 = null,
};
pub fn sendEvent(self: *Self, method: []const u8, p: anytype, opts: CDP_T.SendEventOpts) !void {
// Events ALWAYS go to the client. self.sender should not be used
return self.cdp.sendEvent(method, p, opts);
}
pub fn sendError(self: *Self, code: i32, message: []const u8) !void {
return self.sender.sendJSON(.{
.id = self.input.id,
.code = code,
.message = message,
});
}
const Input = struct {
// When we reply to a message, we echo back the message id
id: ?i64,
// The "action" of the message.Given a method of "LOG.enable", the
// action is "enable"
action: []const u8,
// See notes in BrowserContext about session_id
session_id: ?[]const u8,
// Unparsed / untyped input.params.
params: ?InputParams,
// The full raw json input
json: []const u8,
};
};
}
// When we parse a JSON message from the client, this is the structure
// we always expect
const InputMessage = struct {
id: ?i64 = null,
method: []const u8,
params: ?InputParams = null,
sessionId: ?[]const u8 = null,
};
// The JSON "params" field changes based on the "method". Initially, we just
// capture the raw json object (including the opening and closing braces).
// Then, when we're processing the message, and we know what type it is, we
// can parse it (in Disaptch(T).params).
const InputParams = struct {
raw: []const u8,
pub fn jsonParse(
_: Allocator,
scanner: *json.Scanner,
_: json.ParseOptions,
) !InputParams {
const height = scanner.stackHeight();
const start = scanner.cursor;
if (try scanner.next() != .object_begin) {
return error.UnexpectedToken;
}
try scanner.skipUntilStackHeight(height);
const end = scanner.cursor;
return .{ .raw = scanner.input[start..end] };
}
};
const testing = @import("testing.zig");
test "cdp: invalid json" {
var ctx = testing.context();
defer ctx.deinit();
try testing.expectError(error.InvalidJSON, ctx.processMessage("invalid"));
// method is required
try testing.expectError(error.InvalidJSON, ctx.processMessage(.{}));
try testing.expectError(error.InvalidMethod, ctx.processMessage(.{
.method = "Target",
}));
try ctx.expectSentError(-31998, "InvalidMethod", .{});
try testing.expectError(error.UnknownDomain, ctx.processMessage(.{
.method = "Unknown.domain",
}));
try testing.expectError(error.UnknownMethod, ctx.processMessage(.{
.method = "Target.over9000",
}));
}
test "cdp: invalid sessionId" {
var ctx = testing.context();
defer ctx.deinit();
{
// we have no browser context
try ctx.processMessage(.{ .method = "Hi", .sessionId = "nope" });
try ctx.expectSentError(-32001, "Unknown sessionId", .{});
}
{
// we have a brower context but no session_id
_ = try ctx.loadBrowserContext(.{});
try ctx.processMessage(.{ .method = "Hi", .sessionId = "BC-Has-No-SessionId" });
try ctx.expectSentError(-32001, "Unknown sessionId", .{});
}
{
// we have a brower context with a different session_id
_ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" });
try ctx.processMessage(.{ .method = "Hi", .sessionId = "SESS-1" });
try ctx.expectSentError(-32001, "Unknown sessionId", .{});
}
}
test "cdp: STARTUP sessionId" {
var ctx = testing.context();
defer ctx.deinit();
{
// we have no browser context
try ctx.processMessage(.{ .id = 2, .method = "Hi", .sessionId = "STARTUP" });
try ctx.expectSentResult(null, .{ .id = 2, .index = 0, .session_id = "STARTUP" });
}
{
// we have a brower context but no session_id
_ = try ctx.loadBrowserContext(.{});
try ctx.processMessage(.{ .id = 3, .method = "Hi", .sessionId = "STARTUP" });
try ctx.expectSentResult(null, .{ .id = 3, .index = 0, .session_id = "STARTUP" });
}
{
// we have a brower context with a different session_id
_ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" });
try ctx.processMessage(.{ .id = 4, .method = "Hi", .sessionId = "STARTUP" });
try ctx.expectSentResult(null, .{ .id = 4, .index = 0, .session_id = "STARTUP" });
}
}

117
src/cdp/domains/browser.zig Normal file
View File

@@ -0,0 +1,117 @@
// 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");
// TODO: hard coded data
const PROTOCOL_VERSION = "1.3";
const PRODUCT = "Chrome/124.0.6367.29";
const REVISION = "@9e6ded5ac1ff5e38d930ae52bd9aec09bd1a68e4";
const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
const JS_VERSION = "12.4.254.8";
const DEV_TOOLS_WINDOW_ID = 1923710101;
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
getVersion,
setDownloadBehavior,
getWindowForTarget,
setWindowBounds,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.getVersion => return getVersion(cmd),
.setDownloadBehavior => return setDownloadBehavior(cmd),
.getWindowForTarget => return getWindowForTarget(cmd),
.setWindowBounds => return setWindowBounds(cmd),
}
}
fn getVersion(cmd: anytype) !void {
// TODO: pre-serialize?
return cmd.sendResult(.{
.protocolVersion = PROTOCOL_VERSION,
.product = PRODUCT,
.revision = REVISION,
.userAgent = USER_AGENT,
.jsVersion = JS_VERSION,
}, .{ .include_session_id = false });
}
// TODO: noop method
fn setDownloadBehavior(cmd: anytype) !void {
// const params = (try cmd.params(struct {
// behavior: []const u8,
// browserContextId: ?[]const u8 = null,
// downloadPath: ?[]const u8 = null,
// eventsEnabled: ?bool = null,
// })) orelse return error.InvalidParams;
return cmd.sendResult(null, .{ .include_session_id = false });
}
fn getWindowForTarget(cmd: anytype) !void {
// const params = (try cmd.params(struct {
// targetId: ?[]const u8 = null,
// })) orelse return error.InvalidParams;
return cmd.sendResult(.{ .windowId = DEV_TOOLS_WINDOW_ID, .bounds = .{
.windowState = "normal",
} }, .{});
}
// TODO: noop method
fn setWindowBounds(cmd: anytype) !void {
return cmd.sendResult(null, .{});
}
const testing = @import("../testing.zig");
test "cdp.browser: getVersion" {
var ctx = testing.context();
defer ctx.deinit();
try ctx.processMessage(.{
.id = 32,
.method = "Browser.getVersion",
});
try ctx.expectSentCount(1);
try ctx.expectSentResult(.{
.protocolVersion = PROTOCOL_VERSION,
.product = PRODUCT,
.revision = REVISION,
.userAgent = USER_AGENT,
.jsVersion = JS_VERSION,
}, .{ .id = 32, .index = 0, .session_id = null });
}
test "cdp.browser: getWindowForTarget" {
var ctx = testing.context();
defer ctx.deinit();
try ctx.processMessage(.{
.id = 33,
.method = "Browser.getWindowForTarget",
});
try ctx.expectSentCount(1);
try ctx.expectSentResult(.{
.windowId = DEV_TOOLS_WINDOW_ID,
.bounds = .{ .windowState = "normal" },
}, .{ .id = 33, .index = 0, .session_id = null });
}

View File

@@ -16,9 +16,14 @@
// 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 html: []const u8 =
\\<main id='content'>
\\<a href='foo'>OK</a>
\\<p>blah-blah-blah</p>
\\</main>
;
const std = @import("std");
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
enable,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.enable => return cmd.sendResult(null, .{}),
}
}

320
src/cdp/domains/dom.zig Normal file
View File

@@ -0,0 +1,320 @@
// 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 Node = @import("../Node.zig");
const css = @import("../../browser/dom/css.zig");
const parser = @import("../../browser/netsurf.zig");
const dom_node = @import("../../browser/dom/node.zig");
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
enable,
getDocument,
performSearch,
getSearchResults,
discardSearchResults,
resolveNode,
describeNode,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.enable => return cmd.sendResult(null, .{}),
.getDocument => return getDocument(cmd),
.performSearch => return performSearch(cmd),
.getSearchResults => return getSearchResults(cmd),
.discardSearchResults => return discardSearchResults(cmd),
.resolveNode => return resolveNode(cmd),
.describeNode => return describeNode(cmd),
}
}
// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getDocument
fn getDocument(cmd: anytype) !void {
// const params = (try cmd.params(struct {
// depth: ?u32 = null,
// pierce: ?bool = null,
// })) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const doc = page.doc orelse return error.DocumentNotLoaded;
const node = try bc.node_registry.register(parser.documentToNode(doc));
return cmd.sendResult(.{ .root = bc.nodeWriter(node, .{}) }, .{});
}
// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-performSearch
fn performSearch(cmd: anytype) !void {
const params = (try cmd.params(struct {
query: []const u8,
includeUserAgentShadowDOM: ?bool = null,
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const doc = page.doc orelse return error.DocumentNotLoaded;
const allocator = cmd.cdp.allocator;
var list = try css.querySelectorAll(allocator, parser.documentToNode(doc), params.query);
defer list.deinit(allocator);
const search = try bc.node_search_list.create(list.nodes.items);
// dispatch setChildNodesEvents to inform the client of the subpart of node
// tree covering the results.
try dispatchSetChildNodes(cmd, list.nodes.items);
return cmd.sendResult(.{
.searchId = search.name,
.resultCount = @as(u32, @intCast(search.node_ids.len)),
}, .{});
}
// dispatchSetChildNodes send the setChildNodes event for the whole DOM tree
// hierarchy of each nodes.
// We dispatch event in the reverse order: from the top level to the direct parents.
// We should dispatch a node only if it has never been sent.
fn dispatchSetChildNodes(cmd: anytype, nodes: []*parser.Node) !void {
const arena = cmd.arena;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const session_id = bc.session_id orelse return error.SessionIdNotLoaded;
var parents: std.ArrayListUnmanaged(*Node) = .{};
for (nodes) |_n| {
var n = _n;
while (true) {
const p = try parser.nodeParentNode(n) orelse break;
// Register the node.
const node = try bc.node_registry.register(p);
if (node.set_child_nodes_event) break;
try parents.append(arena, node);
n = p;
}
}
const plen = parents.items.len;
if (plen == 0) return;
var i: usize = plen;
// We're going to iterate in reverse order from how we added them.
// This ensures that we're emitting the tree of nodes top-down.
while (i > 0) {
i -= 1;
const node = parents.items[i];
// Although our above loop won't add an already-sent node to `parents`
// this can still be true because two nodes can share the same parent node
// so we might have just sent the node a previous iteration of this loop
if (node.set_child_nodes_event) continue;
node.set_child_nodes_event = true;
// If the node has no parent, it's the root node.
// We don't dispatch event for it because we assume the root node is
// dispatched via the DOM.getDocument command.
const p = try parser.nodeParentNode(node._node) orelse {
continue;
};
// Retrieve the parent from the registry.
const parent_node = try bc.node_registry.register(p);
try cmd.sendEvent("DOM.setChildNodes", .{
.parentId = parent_node.id,
.nodes = .{bc.nodeWriter(node, .{})},
}, .{
.session_id = session_id,
});
}
}
// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-discardSearchResults
fn discardSearchResults(cmd: anytype) !void {
const params = (try cmd.params(struct {
searchId: []const u8,
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
bc.node_search_list.remove(params.searchId);
return cmd.sendResult(null, .{});
}
// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getSearchResults
fn getSearchResults(cmd: anytype) !void {
const params = (try cmd.params(struct {
searchId: []const u8,
fromIndex: u32,
toIndex: u32,
})) orelse return error.InvalidParams;
if (params.fromIndex >= params.toIndex) {
return error.BadIndices;
}
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const search = bc.node_search_list.get(params.searchId) orelse {
return error.SearchResultNotFound;
};
const node_ids = search.node_ids;
if (params.fromIndex >= node_ids.len) return error.BadFromIndex;
if (params.toIndex > node_ids.len) return error.BadToIndex;
return cmd.sendResult(.{ .nodeIds = node_ids[params.fromIndex..params.toIndex] }, .{});
}
fn resolveNode(cmd: anytype) !void {
const params = (try cmd.params(struct {
nodeId: ?Node.Id = null,
backendNodeId: ?u32 = null,
objectGroup: ?[]const u8 = null,
executionContextId: ?u32 = null,
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
var scope = page.scope;
if (params.executionContextId) |context_id| {
if (scope.context.debugContextId() != context_id) {
const isolated_world = bc.isolated_world orelse return error.ContextNotFound;
scope = isolated_world.scope orelse return error.ContextNotFound;
if (scope.context.debugContextId() != context_id) return error.ContextNotFound;
}
}
const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam;
const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.UnknownNode;
// 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,
params.objectGroup orelse "",
try dom_node.Node.toInterface(node._node),
);
defer remote_object.deinit();
const arena = cmd.arena;
return cmd.sendResult(.{ .object = .{
.type = try remote_object.getType(arena),
.subtype = try remote_object.getSubtype(arena),
.className = try remote_object.getClassName(arena),
.description = try remote_object.getDescription(arena),
.objectId = try remote_object.getObjectId(arena),
} }, .{});
}
fn describeNode(cmd: anytype) !void {
const params = (try cmd.params(struct {
nodeId: ?Node.Id = null,
backendNodeId: ?Node.Id = null,
objectId: ?[]const u8 = null,
depth: u32 = 1,
pierce: bool = false,
})) orelse return error.InvalidParams;
if (params.backendNodeId != null or params.depth != 1 or params.pierce) {
return error.NotYetImplementedParams;
}
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
if (params.nodeId != null) {
const node = bc.node_registry.lookup_by_id.get(params.nodeId.?) orelse return error.NodeNotFound;
return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{}) }, .{});
}
if (params.objectId != null) {
// Retrieve the object from which ever context it is in.
const parser_node = try bc.inspector.getNodePtr(cmd.arena, params.objectId.?);
const node = try bc.node_registry.register(@ptrCast(parser_node));
return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{}) }, .{});
}
return error.MissingParams;
}
const testing = @import("../testing.zig");
test "cdp.dom: getSearchResults unknown search id" {
var ctx = testing.context();
defer ctx.deinit();
try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{
.id = 8,
.method = "DOM.getSearchResults",
.params = .{ .searchId = "Nope", .fromIndex = 0, .toIndex = 10 },
}));
}
test "cdp.dom: search flow" {
var ctx = testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "<p>1</p> <p>2</p>" });
try ctx.processMessage(.{
.id = 12,
.method = "DOM.performSearch",
.params = .{ .query = "p" },
});
try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 12 });
{
// getSearchResults
try ctx.processMessage(.{
.id = 13,
.method = "DOM.getSearchResults",
.params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 2 },
});
try ctx.expectSentResult(.{ .nodeIds = &.{ 0, 1 } }, .{ .id = 13 });
// different fromIndex
try ctx.processMessage(.{
.id = 14,
.method = "DOM.getSearchResults",
.params = .{ .searchId = "0", .fromIndex = 1, .toIndex = 2 },
});
try ctx.expectSentResult(.{ .nodeIds = &.{1} }, .{ .id = 14 });
// different toIndex
try ctx.processMessage(.{
.id = 15,
.method = "DOM.getSearchResults",
.params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 },
});
try ctx.expectSentResult(.{ .nodeIds = &.{0} }, .{ .id = 15 });
}
try ctx.processMessage(.{
.id = 16,
.method = "DOM.discardSearchResults",
.params = .{ .searchId = "0" },
});
try ctx.expectSentResult(null, .{ .id = 16 });
// make sure the delete actually did something
try testing.expectError(error.SearchResultNotFound, ctx.processMessage(.{
.id = 17,
.method = "DOM.getSearchResults",
.params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 },
}));
}

View File

@@ -0,0 +1,66 @@
// 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");
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
setEmulatedMedia,
setFocusEmulationEnabled,
setDeviceMetricsOverride,
setTouchEmulationEnabled,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.setEmulatedMedia => return setEmulatedMedia(cmd),
.setFocusEmulationEnabled => return setFocusEmulationEnabled(cmd),
.setDeviceMetricsOverride => return setDeviceMetricsOverride(cmd),
.setTouchEmulationEnabled => return setTouchEmulationEnabled(cmd),
}
}
// TODO: noop method
fn setEmulatedMedia(cmd: anytype) !void {
// const input = (try const incoming.params(struct {
// media: ?[]const u8 = null,
// features: ?[]struct{
// name: []const u8,
// value: [] const u8
// } = null,
// })) orelse return error.InvalidParams;
return cmd.sendResult(null, .{});
}
// TODO: noop method
fn setFocusEmulationEnabled(cmd: anytype) !void {
// const input = (try const incoming.params(struct {
// enabled: bool,
// })) orelse return error.InvalidParams;
return cmd.sendResult(null, .{});
}
// TODO: noop method
fn setDeviceMetricsOverride(cmd: anytype) !void {
return cmd.sendResult(null, .{});
}
// TODO: noop method
fn setTouchEmulationEnabled(cmd: anytype) !void {
return cmd.sendResult(null, .{});
}

View File

@@ -18,25 +18,12 @@
const std = @import("std");
const parser = @import("netsurf");
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
disable,
}, cmd.input.action) orelse return error.UnknownMethod;
const Node = @import("node.zig").Node;
// WEB IDL https://dom.spec.whatwg.org/#documenttype
pub const DocumentType = struct {
pub const Self = parser.DocumentType;
pub const prototype = *Node;
pub const mem_guarantied = true;
pub fn get_name(self: *parser.DocumentType) ![]const u8 {
return try parser.documentTypeGetName(self);
switch (action) {
.disable => return cmd.sendResult(null, .{}),
}
pub fn get_publicId(self: *parser.DocumentType) ![]const u8 {
return try parser.documentTypeGetPublicId(self);
}
pub fn get_systemId(self: *parser.DocumentType) ![]const u8 {
return try parser.documentTypeGetSystemId(self);
}
};
}

97
src/cdp/domains/input.zig Normal file
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 Page = @import("../../browser/browser.zig").Page;
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
dispatchMouseEvent,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.dispatchMouseEvent => return dispatchMouseEvent(cmd),
}
}
// https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchMouseEvent
fn dispatchMouseEvent(cmd: anytype) !void {
const params = (try cmd.params(struct {
type: Type, // Type of the mouse event.
x: f32, // X coordinate of the event relative to the main frame's viewport.
y: f32, // Y coordinate of the event relative to the main frame's viewport. 0 refers to the top of the viewport and Y increases as it proceeds towards the bottom of the viewport.
// Many optional parameters are not implemented yet, see documentation url.
const Type = enum {
mousePressed,
mouseReleased,
mouseMoved,
mouseWheel,
};
})) orelse return error.InvalidParams;
try cmd.sendResult(null, .{});
// quickly ignore types we know we don't handle
switch (params.type) {
.mouseMoved, .mouseWheel => return,
else => {},
}
const bc = cmd.browser_context orelse return;
const page = bc.session.currentPage() orelse return;
const mouse_event = Page.MouseEvent{
.x = @intFromFloat(@floor(params.x)), // Decimal pixel values are not understood by netsurf or our renderer
.y = @intFromFloat(@floor(params.y)), // So we convert them once at intake here. Using floor such that -0.5 becomes -1 and 0.5 becomes 0.
.type = switch (params.type) {
.mousePressed => .pressed,
.mouseReleased => .released,
else => unreachable,
},
};
try page.mouseEvent(mouse_event);
// result already sent
}
fn clickNavigate(cmd: anytype, uri: std.Uri) !void {
const bc = cmd.browser_context.?;
var url_buf: std.ArrayListUnmanaged(u8) = .{};
try uri.writeToStream(.{
.scheme = true,
.authentication = true,
.authority = true,
.port = true,
.path = true,
.query = true,
}, url_buf.writer(cmd.arena));
const url = url_buf.items;
try cmd.sendEvent("Page.frameRequestedNavigation", .{
.url = url,
.frameId = bc.target_id.?,
.reason = "anchorClick",
.disposition = "currentTab",
}, .{ .session_id = bc.session_id.? });
bc.session.removePage();
_ = try bc.session.createPage(null);
try @import("page.zig").navigateToUrl(cmd, url, false);
}

View File

@@ -16,28 +16,14 @@
// 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 generate = @import("generate.zig");
const std = @import("std");
const Console = @import("jsruntime").Console;
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
enable,
}, cmd.input.action) orelse return error.UnknownMethod;
const DOM = @import("dom/dom.zig");
const HTML = @import("html/html.zig");
const Events = @import("events/event.zig");
const XHR = @import("xhr/xhr.zig");
const Storage = @import("storage/storage.zig");
const URL = @import("url/url.zig");
pub const HTMLDocument = @import("html/document.zig").HTMLDocument;
// Interfaces
pub const Interfaces = generate.Tuple(.{
Console,
DOM.Interfaces,
Events.Interfaces,
HTML.Interfaces,
XHR.Interfaces,
Storage.Interfaces,
URL.Interfaces,
});
pub const UserContext = @import("user_context.zig").UserContext;
switch (action) {
.enable => return cmd.sendResult(null, .{}),
}
}

29
src/cdp/domains/log.zig Normal file
View File

@@ -0,0 +1,29 @@
// 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");
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
enable,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.enable => return cmd.sendResult(null, .{}),
}
}

View File

@@ -0,0 +1,31 @@
// 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");
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
enable,
setCacheDisabled,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.enable => return cmd.sendResult(null, .{}),
.setCacheDisabled => return cmd.sendResult(null, .{}),
}
}

374
src/cdp/domains/page.zig Normal file
View File

@@ -0,0 +1,374 @@
// 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 URL = @import("../../url.zig").URL;
const Notification = @import("../../notification.zig").Notification;
const Page = @import("../../browser/browser.zig").Page;
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
enable,
getFrameTree,
setLifecycleEventsEnabled,
addScriptToEvaluateOnNewDocument,
createIsolatedWorld,
navigate,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.enable => return cmd.sendResult(null, .{}),
.getFrameTree => return getFrameTree(cmd),
.setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd),
.addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd),
.createIsolatedWorld => return createIsolatedWorld(cmd),
.navigate => return navigate(cmd),
}
}
const Frame = struct {
id: []const u8,
loaderId: []const u8,
url: []const u8,
domainAndRegistry: []const u8 = "",
securityOrigin: []const u8,
mimeType: []const u8 = "text/html",
adFrameStatus: struct {
adFrameType: []const u8 = "none",
} = .{},
secureContextType: []const u8,
crossOriginIsolatedContextType: []const u8 = "NotIsolated",
gatedAPIFeatures: [][]const u8 = &[0][]const u8{},
};
fn getFrameTree(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const target_id = bc.target_id orelse return error.TargetNotLoaded;
return cmd.sendResult(.{
.frameTree = .{
.frame = Frame{
.id = target_id,
.loaderId = bc.loader_id,
.securityOrigin = bc.security_origin,
.url = bc.getURL() orelse "about:blank",
.secureContextType = bc.secure_context_type,
},
},
}, .{});
}
fn setLifecycleEventsEnabled(cmd: anytype) !void {
// const params = (try cmd.params(struct {
// enabled: bool,
// })) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
bc.page_life_cycle_events = true;
return cmd.sendResult(null, .{});
}
// TODO: hard coded method
// With the command we receive a script we need to store and run for each new document.
// Note that the worldName refers to the name given to the isolated world.
fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void {
// const params = (try cmd.params(struct {
// source: []const u8,
// worldName: ?[]const u8 = null,
// includeCommandLineAPI: bool = false,
// runImmediately: bool = false,
// })) orelse return error.InvalidParams;
return cmd.sendResult(.{
.identifier = "1",
}, .{});
}
fn createIsolatedWorld(cmd: anytype) !void {
const params = (try cmd.params(struct {
frameId: []const u8,
worldName: []const u8,
grantUniveralAccess: bool,
})) orelse return error.InvalidParams;
if (!params.grantUniveralAccess) {
std.debug.print("grantUniveralAccess == false is not yet implemented", .{});
// When grantUniveralAccess == false and the client attempts to resolve
// or otherwise access a DOM or other JS Object from another context that should fail.
}
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
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.scope.?;
// 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);
return cmd.sendResult(.{ .executionContextId = scope.context.debugContextId() }, .{});
}
fn navigate(cmd: anytype) !void {
const params = (try cmd.params(struct {
url: []const u8,
// referrer: ?[]const u8 = null,
// transitionType: ?[]const u8 = null, // TODO: enum
// frameId: ?[]const u8 = null,
// referrerPolicy: ?[]const u8 = null, // TODO: enum
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
// didn't create?
const target_id = bc.target_id orelse return error.TargetIdNotLoaded;
// didn't attach?
if (bc.session_id == null) {
return error.SessionIdNotLoaded;
}
const url = try URL.parse(params.url, "https");
var page = bc.session.currentPage() orelse return error.PageNotLoaded;
bc.loader_id = bc.cdp.loader_id_gen.next();
try cmd.sendResult(.{
.frameId = target_id,
.loaderId = bc.loader_id,
}, .{});
try page.navigate(url, .{
.reason = .address_bar,
});
}
pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void {
// I don't think it's possible that we get these notifications and don't
// have these things setup.
std.debug.assert(bc.session.page != null);
var cdp = bc.cdp;
const loader_id = bc.loader_id;
const target_id = bc.target_id orelse unreachable;
const session_id = bc.session_id orelse unreachable;
bc.reset();
if (event.reason == .anchor) {
try cdp.sendEvent("Page.frameScheduledNavigation", .{
.frameId = target_id,
.delay = 0,
.reason = "anchorClick",
.url = event.url.raw,
}, .{ .session_id = session_id });
try cdp.sendEvent("Page.frameRequestedNavigation", .{
.frameId = target_id,
.reason = "anchorClick",
.url = event.url.raw,
.disposition = "currentTab",
}, .{ .session_id = session_id });
}
// frameStartedNavigating event
try cdp.sendEvent("Page.frameStartedNavigating", .{
.frameId = target_id,
.url = event.url.raw,
.loaderId = loader_id,
.navigationType = "differentDocument",
}, .{ .session_id = session_id });
// frameStartedLoading event
try cdp.sendEvent("Page.frameStartedLoading", .{
.frameId = target_id,
}, .{ .session_id = session_id });
if (bc.page_life_cycle_events) {
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.name = "init",
.frameId = target_id,
.loaderId = loader_id,
.timestamp = event.timestamp,
}, .{ .session_id = session_id });
}
if (event.reason == .anchor) {
try cdp.sendEvent("Page.frameClearedScheduledNavigation", .{
.frameId = target_id,
}, .{ .session_id = session_id });
}
// When we actually recreated the context we should have the inspector send this event, see: resetContextGroup
// Sending this event will tell the client that the context ids they had are invalid and the context shouls be dropped
// The client will expect us to send new contextCreated events, such that the client has new id's for the active contexts.
try cdp.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id });
var buffer: [512]u8 = undefined;
{
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const aux_data = try std.fmt.allocPrint(fba.allocator(), "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", .{target_id});
bc.inspector.contextCreated(
page.scope,
"",
try page.origin(fba.allocator()),
aux_data,
true,
);
}
if (bc.isolated_world) |*isolated_world| {
const aux_json = try std.fmt.bufPrint(&buffer, "{{\"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.scope.?,
isolated_world.name,
"://",
aux_json,
false,
);
}
}
pub fn pageRemove(bc: anytype) !void {
// The main page is going to be removed, we need to remove contexts from other worlds first.
if (bc.isolated_world) |*isolated_world| {
try isolated_world.removeContext();
}
}
pub fn pageCreated(bc: anytype, page: *Page) !void {
if (bc.isolated_world) |*isolated_world| {
// We need to recreate the isolated world context
try isolated_world.createContext(page);
const polyfill = @import("../../browser/polyfill/polyfill.zig");
try polyfill.load(bc.arena, isolated_world.scope.?);
}
}
pub fn pageNavigated(bc: anytype, event: *const Notification.PageNavigated) !void {
// I don't think it's possible that we get these notifications and don't
// have these things setup.
std.debug.assert(bc.session.page != null);
var cdp = bc.cdp;
const timestamp = event.timestamp;
const loader_id = bc.loader_id;
const target_id = bc.target_id orelse unreachable;
const session_id = bc.session_id orelse unreachable;
// frameNavigated event
try cdp.sendEvent("Page.frameNavigated", .{
.type = "Navigation",
.frame = Frame{
.id = target_id,
.url = event.url.raw,
.loaderId = bc.loader_id,
.securityOrigin = bc.security_origin,
.secureContextType = bc.secure_context_type,
},
}, .{ .session_id = session_id });
// The DOM.documentUpdated event must be send after the frameNavigated one.
// chromedp client expects to receive the events is this order.
// see https://github.com/chromedp/chromedp/issues/1558
try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id });
// domContentEventFired event
// TODO: partially hard coded
try cdp.sendEvent(
"Page.domContentEventFired",
.{ .timestamp = timestamp },
.{ .session_id = session_id },
);
// lifecycle DOMContentLoaded event
// TODO: partially hard coded
if (bc.page_life_cycle_events) {
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.timestamp = timestamp,
.name = "DOMContentLoaded",
.frameId = target_id,
.loaderId = loader_id,
}, .{ .session_id = session_id });
}
// loadEventFired event
try cdp.sendEvent(
"Page.loadEventFired",
.{ .timestamp = timestamp },
.{ .session_id = session_id },
);
// lifecycle DOMContentLoaded event
if (bc.page_life_cycle_events) {
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.timestamp = timestamp,
.name = "load",
.frameId = target_id,
.loaderId = loader_id,
}, .{ .session_id = session_id });
}
// frameStoppedLoading
return cdp.sendEvent("Page.frameStoppedLoading", .{
.frameId = target_id,
}, .{ .session_id = session_id });
}
const LifecycleEvent = struct {
frameId: []const u8,
loaderId: ?[]const u8,
name: []const u8,
timestamp: u32,
};
const testing = @import("../testing.zig");
test "cdp.page: getFrameTree" {
var ctx = testing.context();
defer ctx.deinit();
{
try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Page.getFrameTree", .params = .{ .targetId = "X" } }));
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 });
}
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .target_id = "TID-3" });
{
try ctx.processMessage(.{ .id = 11, .method = "Page.getFrameTree" });
try ctx.expectSentResult(.{
.frameTree = .{
.frame = .{
.id = "TID-3",
.loaderId = bc.loader_id,
.url = "about:blank",
.domainAndRegistry = "",
.securityOrigin = bc.security_origin,
.mimeType = "text/html",
.adFrameStatus = .{
.adFrameType = "none",
},
.secureContextType = bc.secure_context_type,
.crossOriginIsolatedContextType = "NotIsolated",
.gatedAPIFeatures = [_][]const u8{},
},
},
}, .{ .id = 11 });
}
}

View File

@@ -0,0 +1,29 @@
// 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");
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
enable,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.enable => return cmd.sendResult(null, .{}),
}
}

View File

@@ -0,0 +1,90 @@
// 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 builtin = @import("builtin");
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
enable,
runIfWaitingForDebugger,
evaluate,
addBinding,
callFunctionOn,
releaseObject,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.runIfWaitingForDebugger => return cmd.sendResult(null, .{}),
else => return sendInspector(cmd, action),
}
}
fn sendInspector(cmd: anytype, action: anytype) !void {
// save script in file at debug mode
if (builtin.mode == .Debug) {
try logInspector(cmd, action);
}
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
// the result to return is handled directly by the inspector.
bc.callInspector(cmd.input.json);
}
fn logInspector(cmd: anytype, action: anytype) !void {
const script = switch (action) {
.evaluate => blk: {
const params = (try cmd.params(struct {
expression: []const u8,
// contextId: ?u8 = null,
// returnByValue: ?bool = null,
// awaitPromise: ?bool = null,
// userGesture: ?bool = null,
})) orelse return error.InvalidParams;
break :blk params.expression;
},
.callFunctionOn => blk: {
const params = (try cmd.params(struct {
functionDeclaration: []const u8,
// objectId: ?[]const u8 = null,
// executionContextId: ?u8 = null,
// arguments: ?[]struct {
// value: ?[]const u8 = null,
// objectId: ?[]const u8 = null,
// } = null,
// returnByValue: ?bool = null,
// awaitPromise: ?bool = null,
// userGesture: ?bool = null,
})) orelse return error.InvalidParams;
break :blk params.functionDeclaration;
},
else => return,
};
const id = cmd.input.id orelse return error.RequiredId;
const name = try std.fmt.allocPrint(cmd.arena, "id_{d}.js", .{id});
var dir = try std.fs.cwd().makeOpenPath(".zig-cache/tmp", .{});
defer dir.close();
const f = try dir.createFile(name, .{});
defer f.close();
try f.writeAll(script);
}

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