359 Commits

Author SHA1 Message Date
Karl Seguin
77aa2241dc Merge pull request #1520 from lightpanda-io/robots-fix-wikipedia
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix how `robots.txt`handles utf8
2026-02-11 14:05:38 +08:00
Karl Seguin
0766d08479 Merge pull request #1508 from lightpanda-io/selectionchange-event
Support `selectionchange` Event
2026-02-11 14:02:44 +08:00
Karl Seguin
f6ed0d43a2 Merge pull request #1521 from lightpanda-io/update-nix-flake
Update Nix Flake
2026-02-11 13:53:10 +08:00
Muki Kiboigo
c8413cb029 run selectionchange tests eventually 2026-02-10 21:48:56 -08:00
Karl Seguin
97d53b81a7 Give EventManager.dispatch and explicit error set
This allows potentially recursive callers to use an implicit error set return.
2026-02-11 12:50:59 +08:00
Muki Kiboigo
ab888f5cd0 update nix flake 2026-02-10 20:37:19 -08:00
Muki Kiboigo
f54246eac1 remove anyerror from TextArea dispatchSelectionChangeEvent 2026-02-10 20:35:37 -08:00
Muki Kiboigo
7de9422b75 strip utf8 bom from start of robots.txt 2026-02-10 20:29:39 -08:00
Muki Kiboigo
f02a37d3f0 properly handle failed parsing on robots 2026-02-10 20:09:32 -08:00
Karl Seguin
28815a0ae6 Merge pull request #1518 from lightpanda-io/visual_viewport
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
implement a dummy VisualViewport
2026-02-11 07:14:54 +08:00
Karl Seguin
70c7dfd0f4 Merge pull request #1517 from lightpanda-io/input_type_change
Sanitize input values based on type (and on type change)
2026-02-11 07:14:44 +08:00
Karl Seguin
9c2ebd308b Merge pull request #1516 from lightpanda-io/unhandle_rejection_callback
Adds PromiseRejectionCallback
2026-02-11 07:14:26 +08:00
Karl Seguin
11d8412591 Merge pull request #1514 from lightpanda-io/selection_wpt_fixes
Fixes extend-20.html and extend-00.html
2026-02-11 07:14:14 +08:00
Karl Seguin
32ca170c4d Merge pull request #1513 from lightpanda-io/fix_crash_on_double_http_abort
Fix double-free of XHR on double client abort
2026-02-11 07:14:01 +08:00
Karl Seguin
388ed08b0e Merge pull request #1512 from lightpanda-io/fix_scheduled_navigation
Fix schedule navigation
2026-02-11 07:13:48 +08:00
Pierre Tachoire
b408f88b8c Merge pull request #1519 from lightpanda-io/readme-robotstxt
Update README w/ robots.txt
2026-02-10 14:40:49 +01:00
Pierre Tachoire
09087401b4 update README examples 2026-02-10 14:35:39 +01:00
Pierre Tachoire
c68692d78e add --obey_robots into README examples 2026-02-10 14:24:38 +01:00
Karl Seguin
ee2a4d0a5d implement a dummy VisualViewport 2026-02-10 18:08:52 +08:00
Karl Seguin
a15885fe80 Merge pull request #1505 from lightpanda-io/class_name_tokenizer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix class name iterator
2026-02-10 17:07:51 +08:00
Karl Seguin
24111570cf Sanitize input values based on type (and on type change)
Per spec.
2026-02-10 16:59:00 +08:00
Karl Seguin
ded203b1c1 Adds PromiseRectionCallback
Fires the window.onunhandledrejection. This API is a bit different than
everything else, because it's entered from the Isolate/Env. So there's a bit
more js -> webapi awareness baked into Env now to handle it.

Also touched up existing Events that have .Global data and changed it to .Temp
and cleaned it up in their deinit.
2026-02-10 16:50:11 +08:00
Karl Seguin
e43fc98c0d Merge pull request #1511 from lightpanda-io/remove_unused_cache
Remove unused cache v8 bridge hint.
2026-02-10 16:11:07 +08:00
Karl Seguin
1efd13545e apply similar fix to selectAllChildren 2026-02-10 12:23:41 +08:00
Karl Seguin
1193ee1ab9 Fixes extend-20.html and extend-00.html 2026-02-10 12:04:02 +08:00
Karl Seguin
a6ba801738 Fix double-free of XHR on double client abort
It's possible (in fact normal) for client.abort to be called twice on a schedule
navigation. We immediately abort any pending requests once a secondary
navigation is called (is that right?), and then again when the page shuts down.

The first abort will kill the transfer, so the XHR object has to null this value
so that, on context shutdown, when the finalizer is called, we don't try to
free it again.
2026-02-10 11:30:10 +08:00
Karl Seguin
e7958f2910 Fix schedule navigation
Previously, the Session depended on the page to return a .navigate state when
a secondary navigation should take place. There were a lot of places where the
page returned .done rather than .navigate.

To simplify the code, .navigate is no longer a WaitResult. The page now just has
to return .done, and the Session will check if there's a queued sub-navigation.

This will fix cases where sub-navigation is triggered by a script being executed
as the last step of page loading.
2026-02-10 10:36:18 +08:00
Karl Seguin
cbac9a7703 Remove unused cache v8 bridge hint.
Pre-zigdom, we could use the postAttach behavior to store commonly accessed and
never-changed accessors directly on the v8::object. I've tried to re-enable this
feature a couple times, and have failed.

Between the new Snapshot and the always-on unhandled property handler for Window
it never seems to work very well. Plus, Gemini continues to insist that changing
the shape of the Object is more like to hurt than help performance.

There's possibly an optimization here worth revisiting in the future, but for
the time being, this code does nothing except for possibly deceive a reader that
some optimization is going on when it isn't.
2026-02-10 09:57:28 +08:00
Karl Seguin
60d8f2323e Merge pull request #1509 from lightpanda-io/atob-trim
window.atob must trim input
2026-02-10 09:54:31 +08:00
Karl Seguin
70ae6b8d72 Merge pull request #1407 from lightpanda-io/robots
Support for `robots.txt`
2026-02-10 09:51:32 +08:00
Muki Kiboigo
e1850440b0 shutdown queued req on robots shutdown 2026-02-09 15:24:35 -08:00
Pierre Tachoire
d5c2aaeea3 window.atob must trim input 2026-02-09 17:08:32 +01:00
Karl Seguin
a06b7acc85 Merge pull request #1501 from lightpanda-io/html_picture_element
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Add HTMLPictureElement
2026-02-09 23:00:59 +08:00
Pierre Tachoire
615168423a Merge pull request #1482 from lightpanda-io/nikneym/failing-document-element-test
Add failing `document.documentElement` `load` event test
2026-02-09 15:56:44 +01:00
Muki Kiboigo
73abf7d20e add tests for selectionchange in HTML Elements 2026-02-09 06:51:26 -08:00
Muki Kiboigo
fcea42e91e properly expose selection api in HTMLTextAreaElement 2026-02-09 06:51:17 -08:00
Pierre Tachoire
14f7574c41 ci: run test on any change into src/ 2026-02-09 15:47:11 +01:00
Muki Kiboigo
8f15ded650 use anyerror for dispatchSelectionChangeEvent 2026-02-09 06:44:32 -08:00
Muki Kiboigo
4aec4ef80a add selectionchange to HTMLTextAreaElement 2026-02-09 06:42:35 -08:00
Muki Kiboigo
ecb8f1de30 add selectionchange to HTMLInputElement 2026-02-09 06:41:58 -08:00
Pierre Tachoire
4c28180125 Merge pull request #1503 from lightpanda-io/node_iterator
Fix 3 WPT cases for NodeIterator
2026-02-09 15:39:24 +01:00
Pierre Tachoire
4138180f43 Merge pull request #1506 from lightpanda-io/allow-propfind-method
http: allow PROPOFIND http method
2026-02-09 15:35:44 +01:00
Muki Kiboigo
0d508a88f6 add selectionchange tests 2026-02-09 06:32:36 -08:00
Muki Kiboigo
7c8fcf73f6 dispatch selectionchange when Selection changes 2026-02-09 06:27:50 -08:00
Karl Seguin
5904d72776 Merge pull request #1504 from lightpanda-io/selection_fix_2
WPT Selection fixes
2026-02-09 22:27:38 +08:00
Muki Kiboigo
5e32ccbf12 add selectionchange handler on Document 2026-02-09 06:27:34 -08:00
Karl Seguin
6ce136bede Merge pull request #1502 from lightpanda-io/selection_fix
Improve Selection compatibility
2026-02-09 22:27:13 +08:00
Pierre Tachoire
b9f8ce5729 http: allow PROPOFIND http method
PROPOFIND is used by webdav to retrieve resource's properties.
http://www.webdav.org/specs/rfc4918.html#METHOD_PROPFIND

For example, Nextcloud public sharing uses PROPOFIND to list shared resources.
2026-02-09 15:16:28 +01:00
Pierre Tachoire
115530a104 http: add PROPOFIND method test case 2026-02-09 15:16:17 +01:00
Muki Kiboigo
65c9b2a5f7 add robotsShutdownCallback 2026-02-09 05:51:42 -08:00
Muki Kiboigo
46c73a05a9 panic instead of unreachable on robots callbacks 2026-02-09 05:35:32 -08:00
Karl Seguin
c5f7e72ca8 Fix class name iterator
Used to use std.ascii.whitespace, but per spec, it's only a subset of that which
are valid separators. Other characters, line line tabulation, are valid class
names.
2026-02-09 18:34:53 +08:00
Karl Seguin
e6fb63ddba WPT Selection fixes
Same as https://github.com/lightpanda-io/browser/pull/1502 but applied to other
functions.
2026-02-09 18:25:28 +08:00
Karl Seguin
e2645e4126 Fix 3 WPT cases for NodeIterator
- Prevent recursive filters
- Rethrow on filter exception
- Add no-op (as per spec) `detach`
2026-02-09 18:19:36 +08:00
Karl Seguin
36d267ca40 Improve Selection compatibility
collapse-30.html WPT test from from 2753/5133 to 5133/5133.

-Return dom_exception from setPosition
-Verify node containment in document
2026-02-09 17:38:11 +08:00
Karl Seguin
2e5d04389b Merge pull request #1479 from lightpanda-io/event_finalizers_and_arenas
Add Finalizers to events
2026-02-09 17:10:08 +08:00
Karl Seguin
6130ed17a6 Update src/browser/js/Local.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2026-02-09 16:56:45 +08:00
Karl Seguin
c4e82407ec Add Finalizers to events
At a high level, this does for Events what was recently done for XHR, Fetch and
Observers. Events are self-contained in their own arena from the ArenaPool and
are registered with v8 to be finalized.

But events are more complicated than those other types. For one, events have
a prototype chain. (XHR also does, but it's always the top-level object that's
created, whereas it's valid to create a base Event or something that inherits
from Event). But the _real_ complication is that Events, unlike previous types,
can be created from Zig or from V8.

This is something that Fetch had to deal with too, because the Response is only
given to V8 on success. So in Fetch, there's a period of time where Zig is
solely responsible for the Response, until it's passed to v8. But with events
it's a lot more subtle.

There are 3 possibilities:
1 - An Event is created from v8. This is the simplest, and it simply becomes a
    a weak reference for us. When v8 is done with it, the finalizer is called.
2 - An Event is created in Zig (e.g. window.load) and dispatched to v8. Again
    we can rely on the v8 finalizer.
3 - An event is created in Zig, but not dispatched to v8 (e.g. there are no
    listeners), Zig has to release the event.

(It's worth pointing out that one thing that still keeps this relatively
straightforward is that we never hold on to Events past some pretty clear point)

Now, it would seem that #3 is the only issue we have to deal with, and maybe
we can do something like:

```
if (event_manager.hasListener("load", capture)) {
   try event_manager.dispatch(event);
} else {
   event.deinit();
}
```

In fact, in many cases, we could use this to optimize not even creating the
event:

```
if (event_manager.hasListener("load, capture)) {
   const event = try createEvent("load", capture);
   try event_manager.dispatch(event);
}
```

And that's an optimization worth considering, but it isn't good enough to
properly manage memory. Do you see the issue? There could be a listener (so we
think v8 owns it), but we might never give the value to v8. Any failure between
hasListener and actually handing the value to v8 would result in a leak.

To solve this, the bridge will now set a _v8_handover flag (if present) once it
has created the finalizer_callback entry. So dispatching code now becomes:

```
const event = try createEvent("load", capture);
defer if (!event._v8_handover) event.deinit(false);
try event_manager.dispatch(event);
```

The v8 finalizer callback was also improved. Previously, we just embedded the
pointer to the zig object. In the v8 callback, we could cast that back to T
and call deinit. But, because of possible timing issues between when (if) v8
calls the finalizer, and our own cleanup, the code would check in the context to
see if the ptr was still valid. Wait, what? We're using the ptr to get the
context to see if the ptr is valid?

We now store a pointer to the FinalizerCallback which contains the context.
So instead of something stupid like:

```
// note, if the identity_map doesn't contain the value, then value is likely
// invalid, and value.page will segfault
value.page.js.identity_map.contains(@intFromPtr(value))
```

We do:
```
if (fc.ctx.finalizer_callbacks.contains(@intFromPtr(fc.value)) {
  // fc.value is safe to use
}
```
2026-02-09 16:56:43 +08:00
Halil Durak
2e28e68c48 make sure async test is dispatched
Better than `testing.expectEqual(true, true)`.
2026-02-09 11:45:31 +03:00
Halil Durak
a7882fa32b add failing document.documentElement load event test
make sure that we're in capturing phase

`testing.expectEqual(true, result)`
2026-02-09 11:41:56 +03:00
Karl Seguin
82f9e70406 Merge pull request #1498 from lightpanda-io/fast_properties
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Convert more comptime-getters to fast getters
2026-02-09 16:04:10 +08:00
Karl Seguin
3fa27c7ffa Merge pull request #1500 from lightpanda-io/console_table
Add console.table
2026-02-09 16:02:40 +08:00
Karl Seguin
1aa50dee20 Merge pull request #1499 from lightpanda-io/http_max_response_size
Add http_max_response_size
2026-02-09 16:02:27 +08:00
Halil Durak
f58f7257be Merge pull request #1487 from lightpanda-io/nikneym/doc-element-dispatch
Changes to trigger `documentElement`s load event
2026-02-09 10:52:53 +03:00
Halil Durak
e17f1269a2 remove unnecessary comment and comptime if 2026-02-09 10:43:22 +03:00
Halil Durak
82f48b84b3 changes to trigger documentElements load event
This fixes the triggering issue; though the order of execution is wrong...
2026-02-09 10:41:42 +03:00
Karl Seguin
926bd20281 Add HTMLPictureElement
Fix tag mapping for various types (including Source, which is Picture related).
2026-02-09 15:38:41 +08:00
Karl Seguin
a6cd019118 Add http_max_response_size
This adds a --http_max_response_size argument to the serve and fetch command
which is enforced by the HTTP client. This defaults to null, no limit.

As-is, the ScriptManager allocates a buffer based on Content-Length. Without
setting this flag, a server could simply reply with Content-Length: 99999999999
9999999999  to cause an OOM. This new flag is checked both once we have the
header if there's a content-length, and when reading the body.

Also requested in https://github.com/lightpanda-io/browser/issues/415
2026-02-09 13:16:18 +08:00
Karl Seguin
bbfc476d7e Add console.table
+ fix unknown object property filters
2026-02-09 12:38:35 +08:00
Karl Seguin
8d49515a3c Convert more comptime-getters to fast getters
Follow up to https://github.com/lightpanda-io/browser/pull/1495 which introduced
the concept of a fast getter. This commit expands the new behavior to all
comptime-known scalar getters.

It also leverages the new `v8__FunctionTemplate__New__Config` to
1 - flag fast getters as having no side effect
2 - set the length (arity) on all functions
2026-02-09 11:35:27 +08:00
Karl Seguin
0a410a5544 Merge pull request #1495 from lightpanda-io/fast_getter
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Adds Document.prerendering
2026-02-08 21:45:06 +08:00
Karl Seguin
eef203633b Adds Document.prerendering
Expands bridge.property to work as a getter. This previously only worked by
setting a value directly on the TemplatePrototype. This is what you want for
something like Node.TEXT_NODE which is accessible on both Node and an instance
(e.g. document.createElement('div').TEXT_NODE).

Now the property can be configured with .{.template = false}. It essentially
becomes an optimized: bridge.accessor(comptime scalar, null, .{});

There are other accessor that can be converted to this type, but I'll do that
after this is merged to keep this PR manageable.
2026-02-08 20:43:58 +08:00
Pierre Tachoire
122255058e Merge pull request #1497 from lightpanda-io/improve_debug_logs
Reduce some debug logs
2026-02-08 10:47:52 +01:00
Pierre Tachoire
b1681b2213 Merge pull request #1496 from lightpanda-io/remove_custom_element_placeholder_name
Remove the "TODO-CUSTOM-NAME" name on custom elements
2026-02-08 10:46:37 +01:00
Pierre Tachoire
73d79f55d8 Merge pull request #1494 from lightpanda-io/window_opener
Add dummy window.opener accessor
2026-02-08 10:43:17 +01:00
Pierre Tachoire
a02fcd97d6 Merge pull request #1493 from lightpanda-io/document_visibility
Add Document.hidden and visibilityState properties
2026-02-08 10:42:33 +01:00
Pierre Tachoire
016708b338 Merge pull request #1492 from lightpanda-io/create_element_case_insensitivity
Make document.createElement and createElementNS case-insensitive for …
2026-02-08 10:41:51 +01:00
Pierre Tachoire
9f26fc28c4 Merge pull request #1491 from lightpanda-io/xhr_with_credentials
Add support for XHR's withCredentials
2026-02-08 10:38:02 +01:00
Pierre Tachoire
7c1b354fc3 Merge pull request #1490 from lightpanda-io/missing_properties
Add a number of [simple] missing properties
2026-02-08 10:36:28 +01:00
Pierre Tachoire
abeda1935d Merge pull request #1489 from lightpanda-io/fix_profile_stringifiers
Fix profiler stringifiers
2026-02-08 10:34:17 +01:00
Karl Seguin
403ee9ff9e Reduce some debug logs
1 - Remove the double logging of "load" dispatch
2 - On even registration, just log the event target type, not the full node
3 - Most significantly, rather than logging unknown properties (both global and
    per object) as they happen, collect them and log the on context ends. This
    log includes a count of how often it happened. On some pages, this reduces
    the number of unknown property messages by thousands.
2026-02-08 14:48:22 +08:00
Karl Seguin
cccb45fe13 Remove the "TODO-CUSTOM-NAME" name on custom elements
Giving a WebAPI a JsApi.Meta.name registers that name on the global object, e.g
window['TODO-CUSTOM-NAME'] becomes a type.
2026-02-08 09:56:53 +08:00
Nikolay Govorov
8d43becb27 Merge pull request #1488 from lightpanda-io/sighandler_serve_only
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Move Sighandler to "serve" mode only
2026-02-07 20:32:14 +00:00
Karl Seguin
ee22e07fff Add dummy window.opener accessor
This seems pretty complicated to implement...mostly because we'd have to keep
a window around in certain cases and because exactly what's accessible on that
window depends on a few factors.
2026-02-07 17:51:58 +08:00
Karl Seguin
37464e2d95 Add Document.hidden and visibilityState properties
Always visible. I understand that this is for things like, being on a different
tab or being minimized.
2026-02-07 17:39:12 +08:00
Karl Seguin
5abcecbc9b Make document.createElement and createElementNS case-insensitive for HTML NS
This could more cleanly be applied to page.createElementNS, but that would add
overhead to the parser. Alternative would be to pass a comptime flag to
page.createElementNS (e.g. like the from_parser we pass elsewhere).
2026-02-07 17:21:11 +08:00
Karl Seguin
cecdf0d511 Add support for XHR's withCredentials
XHR should only send and receive cookies for same-origin requests or if
withCredentials is true.
2026-02-07 16:16:10 +08:00
Karl Seguin
a451fe4248 Add a number of [simple] missing properties 2026-02-07 15:28:53 +08:00
Karl Seguin
d6f801f764 Fix profiler stringifiers
This code is commented out by default, so it's easy to get out of date and get
missed by refactorings.
2026-02-07 10:31:25 +08:00
Karl Seguin
a35e772a6b Move Sighandler to "serve" mode only
Currently the sighandler is setup regardless of the running mode, but it only
does something in "serve" mode. In fetch mode, since there are no registered
listeners, it intercepts the signal and does nothing. On MacOS at least, this
isn't a great experience as it can leave the process running in the background.
2026-02-07 08:34:59 +08:00
Karl Seguin
aca3fae6b1 Merge pull request #1484 from lightpanda-io/fix-replace-with
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
fix Element.replaceWith crash when self replacing
2026-02-07 07:40:47 +08:00
Karl Seguin
17891f0209 Merge pull request #1485 from lightpanda-io/console-fix
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
add console test
2026-02-07 07:35:47 +08:00
Karl Seguin
aea2b3c8e5 remove empty_with_no_proto from console, since it isn't empty 2026-02-07 07:26:02 +08:00
Pierre Tachoire
57fb167a9c add console test 2026-02-06 19:19:36 +01:00
Pierre Tachoire
0406bba384 fix Element.replaceWith crash when self replacing
Fix a crash with the WPT test dom/nodes/ChildNode-replaceWith.html
When we call `div.replaceWith(div);` we don't want to touch self at all.
2026-02-06 18:25:04 +01:00
Karl Seguin
7c4c80fe4a Merge pull request #1478 from lightpanda-io/filter_jquery_unknown_object_property
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Filter out jquery* unknown object property
2026-02-06 10:54:53 +08:00
Karl Seguin
bfb267e164 Filter out jquery* unknown object property 2026-02-06 07:55:41 +08:00
Karl Seguin
a0720948a1 Merge pull request #1417 from lightpanda-io/observer_arenas
Leverage finalizers and ArenaPool in Intersction and Mutation Observer
2026-02-06 07:42:38 +08:00
Karl Seguin
9f00159a84 Merge pull request #1475 from lightpanda-io/event_lookup_include_type
Change the listener lookup from element, to (element,type).
2026-02-06 07:41:43 +08:00
Muki Kiboigo
34067a1d70 only use eqlIgnoreCase for RobotStore 2026-02-05 08:02:35 -08:00
Halil Durak
3f6917fdcb Merge pull request #1477 from lightpanda-io/nikneym/load-event-fix
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Create distinct `Event` objects for each `load` event
2026-02-05 17:01:30 +03:00
Halil Durak
c04a6e501e create distinct Event objects for each load event 2026-02-05 16:47:09 +03:00
Karl Seguin
661b564399 rename field element -> event_target 2026-02-05 20:26:43 +08:00
Halil Durak
761c103373 Merge pull request #1389 from lightpanda-io/nikneym/image-src-dispatch
`Image` & `Style`: Dispatch `load` event
2026-02-05 15:23:38 +03:00
Karl Seguin
f4bd9e3d24 Merge pull request #1476 from lightpanda-io/log_unknown_object_properties
Log unknown object properties in debug builds
2026-02-05 19:25:17 +08:00
Karl Seguin
b9ddac878c Log unknown object properties in debug builds
We currently log any unknown property access on the global (window). This has
proven pretty useful when debugging, often letting us know a type we're
completely missing.

However, it isn't just about what types we have, it's also about the properties
those types expose. Inspired by the wasted time spent on
https://github.com/lightpanda-io/browser/pull/1473, this commit adds the same
type of logging for unknown properties on objects. In debug mode only. This is
only done for objects which don't have their own NamedIndexer.
2026-02-05 17:35:15 +08:00
Karl Seguin
f304ce5ccf Change the listener lookup from element, to (element,type).
Right now, to do anything with listeners, we need to (a) lookup the listeners
registered with the element and (b) walk the linked list to find listeners of
the correct type.

There's no operations that behave on all of an element's listeners, e.g. there's
no element.removeAllListeners(). So, we can optimize the code, and generally
make it simpler to read, by changing our key to include the type (along with
the element).

This is an optimization on its own (and makes the code simpler), but it also
makes it more palatable to do a pre-listener check to avoid creating events when
no listener even exists; if we decide to implement that.
2026-02-05 16:45:29 +08:00
Karl Seguin
828401f057 Merge pull request #1474 from lightpanda-io/zig_fmt
zig fmt
2026-02-05 16:39:29 +08:00
Karl Seguin
445d77a220 Merge pull request #1473 from lightpanda-io/script_text
Expose Script.text property
2026-02-05 16:38:51 +08:00
Karl Seguin
4d768bb5eb zig fmt 2026-02-05 16:24:13 +08:00
Karl Seguin
4e3b87d338 remove extra newline 2026-02-05 16:22:50 +08:00
Karl Seguin
00740b6117 Expose Script.text property
This should fix a number of rendering issues, but was specifically added while
investigating issues rendering sites that used knockout.js
2026-02-05 16:21:03 +08:00
Karl Seguin
7775f203fc Merge pull request #1471 from lightpanda-io/arraylistunmanaged_to_arraylist
Rename all ArrayListUnmanaged -> ArrayList
2026-02-05 15:49:06 +08:00
Karl Seguin
945af879ec Merge pull request #1472 from lightpanda-io/wpt_memory_leak
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
free config on wpt test end
2026-02-05 14:40:34 +08:00
Karl Seguin
b2506f0afe free config on wpt test end 2026-02-05 14:30:46 +08:00
Karl Seguin
2eab4b84c9 Rename all ArrayListUnmanaged -> ArrayList
ArrayListAlignedUnmanaged has been deprecated for a while, and I occasionally
replace them, but doing one complete pass gets it done once and for all.
2026-02-05 11:49:15 +08:00
Karl Seguin
7746d9968d Merge pull request #1470 from lightpanda-io/fix_bsd_test_server_shutdown
use close, not shutdown on BSD/Mac
2026-02-05 11:25:37 +08:00
Karl Seguin
da49d918d6 use close, not shutdown on BSD/Mac 2026-02-05 10:58:10 +08:00
Karl Seguin
804ed758c9 Leverage finalizers and ArenaPool in Intersction and Mutation Observer
Both of these become self-contained with their own arena, which can be cleaned
up when (a) we don't need it anymore and (b) v8 doens't need it. The "we don't
need it anymore" is true when there's nothing being observed.

The underlying records also get their own arena and are handed off to v8 to
finalize whenever they fall out of scope.
2026-02-05 08:05:34 +08:00
Karl Seguin
17aac58e08 Merge pull request #1468 from lightpanda-io/context_safety
Fixes a few context issues.
2026-02-05 07:51:46 +08:00
Muki Kiboigo
a7095d7dec pass robot store into Http init 2026-02-04 12:23:42 -08:00
Halil Durak
3afbb6fcc2 load event dispatching for Style 2026-02-04 23:22:07 +03:00
Halil Durak
8ecbd8e71c add Page._to_load and implement load even dispatching for Image 2026-02-04 23:22:07 +03:00
Halil Durak
988f499723 EventManager: add hasListener
Not sure if this should be in `EventTarget` or `EventManager`, here goes nothing.

`Image`: dispatch `load` event when `src` set
add load event test

remove `hasListener`

let `Scheduler` dispatch `load` event

Simulates async nature.

update test

free `args` when done

implement `load` event dispatch for `<img>` tags

This dispatches `load` events tied to `EventManager` but not the `onload` for some reason...

`"load"` event must be dispatched even if `onload` not set

Resolves the bug that cause event listeners added through `EventTarget` not executing if `onload` not set.

add `onload` getter/setter for `Image`

prefer `attributeChange` to run side-effects

This should give more consistent results than using `setSrc`.
add inline `<img src="..." />` test

`Image`: prefer `inline_lookup` for `onload`

remove incorrect URL check + prefer 0ms in `Scheduler`

change after rebase
2026-02-04 23:22:06 +03:00
Muki Kiboigo
50aeb9ff21 add comment explaining rule choice in robots 2026-02-04 12:19:18 -08:00
Muki Kiboigo
e620c28a1c stop leaking robots_url when in robot queue 2026-02-04 12:19:18 -08:00
Muki Kiboigo
29ee7d41f5 queue requests to run after robots is fetched 2026-02-04 12:19:17 -08:00
Muki Kiboigo
f9104c71f6 log instead of returning error on unexpected rule 2026-02-04 12:16:36 -08:00
Muki Kiboigo
b6af5884b1 use RobotsRequestContext deinit 2026-02-04 12:16:36 -08:00
Muki Kiboigo
e4f250435d include robots url in debug log 2026-02-04 12:16:36 -08:00
Muki Kiboigo
1a246f2e38 robots in the actual http client 2026-02-04 12:15:49 -08:00
Muki Kiboigo
48ebc46c5f add getRobotsUrl to URL 2026-02-04 12:09:05 -08:00
Muki Kiboigo
e27803038c initial implementation of Robots 2026-02-04 12:09:05 -08:00
Pierre Tachoire
babf8ba3e7 Merge pull request #1469 from lightpanda-io/ci-v8-debug
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
ci: use v8 debug mode
2026-02-04 15:20:23 +01:00
Nikolay Govorov
6ccd3f277b Fix race condition 2026-02-04 13:48:07 +00:00
Halil Durak
9d6f9aae9a Merge pull request #1440 from lightpanda-io/nikneym/lazy-eval-handlers
Lazily evaluate inline event listeners
2026-02-04 16:45:09 +03:00
Halil Durak
95a000c279 getOnSubmit can return errors 2026-02-04 16:15:02 +03:00
Halil Durak
b19debff14 move everything to global_event_handlers.zig 2026-02-04 16:11:02 +03:00
Halil Durak
39c9024747 !?js.Function.Global
Also more clear warning in `stringToPersistedFunction` failure.
2026-02-04 16:11:02 +03:00
Halil Durak
3c660f2cb0 remove unnecessary comptime 2026-02-04 16:11:02 +03:00
Halil Durak
13dbdc7dc7 import order 2026-02-04 16:11:02 +03:00
Halil Durak
f903e4b2de bunch of renaming 2026-02-04 16:11:01 +03:00
Halil Durak
b96cb2142b add getAttributeFunction 2026-02-04 16:11:01 +03:00
Halil Durak
cc51cd4476 remove eager event listener parsing 2026-02-04 16:11:01 +03:00
Halil Durak
8a995fc515 createLookupKey -> calcAttrListenerKey 2026-02-04 16:11:01 +03:00
Halil Durak
078eccea2d update doc comment 2026-02-04 16:11:00 +03:00
Karl Seguin
190119bcd4 Merge pull request #1461 from lightpanda-io/blocking_auth_intercept_fix
Fix [I hope] blocking auth interception
2026-02-04 19:40:06 +08:00
Pierre Tachoire
7672b42fbc ci: add missing -Dtsan=tru option 2026-02-04 12:13:46 +01:00
Pierre Tachoire
c590658f16 ci: use debug v8 with zig test 2026-02-04 12:08:12 +01:00
Karl Seguin
017d4e792b Fix [I hope] blocking auth interception
On a blocking request that requires authentication, we now handle the two cases
correctly:
1 - if the request is aborted, we don't continue processing (if we did, that
    would result in (a) transfer.deinit being called twice and (b) the callbacks
    being called twice

2 - if the request is "continue", we queue the transfer to be re-issued, as
    opposed to just processing it as-is. We have to queue it because we're
    currently inside a process loop and it [probaby] isn't safe to re-enter it.
    By using the queue, we wait until the next call to `tick` to re-issue the
    request.
2026-02-04 18:39:23 +08:00
Karl Seguin
0671be870d Fixes a few context issues.
First, for macrotasks, it ensures that the correct context is entered.

More important, for microtasks, it protects against use-after-free. This is
something we already did, to some degree, in page.deinit: we would run
microtasks before erasing the page, in case any microtasks referenced the page.

The new approach moves the guard to the context (since we have multiple
contexts) and protects against a microtasks enqueue a new task during shutdown
(something the original never did).
2026-02-04 18:34:33 +08:00
Pierre Tachoire
2f9ed37db2 ci: remove invalid install option 2026-02-04 11:18:38 +01:00
Karl Seguin
2cf2db3eef Merge pull request #1466 from lightpanda-io/form_onsubmit
Execute form.onsubmit when a form is being submitted
2026-02-04 18:13:15 +08:00
Karl Seguin
11ad025e5d Merge pull request #1467 from lightpanda-io/inspector_context_destroyed
Call Inpsector::ContextDestroyed
2026-02-04 18:12:58 +08:00
Karl Seguin
630cf05b2f Merge pull request #1463 from lightpanda-io/wp/mrdimidium/cleanup-configuration
Centralizes configuration, eliminates unnecessary copying of config
2026-02-04 17:36:39 +08:00
Nikolay Govorov
a72782f91e Eliminates duplication in the creation of HTTP headers 2026-02-04 09:08:57 +00:00
Karl Seguin
fbd554a15f Call Inpsector::ContextDestroyed
This seems to solve some potential use-after-free issues. By informing the
Inspector that the context is gone, it seems to effectively ensure that no more
messages are sent from the inspector for things related to the context.
2026-02-04 16:24:48 +08:00
Nikolay Govorov
f71aa1cad2 Centralizes configuration, eliminates unnecessary copying of config 2026-02-04 07:57:59 +00:00
Nikolay Govorov
6d9517f6ea Merge pull request #1465 from lightpanda-io/wp/mrdimidium/direct-telemetry
Telemetry without notifications
2026-02-04 07:51:46 +00:00
Nikolay Govorov
fd8c488dbd Move Notification from App to BrowserContext 2026-02-04 07:33:45 +00:00
Nikolay Govorov
dbf18b90a7 Removes telemetry dependence on notifications 2026-02-04 07:30:38 +00:00
Pierre Tachoire
d318fe24b8 Merge pull request #1460 from lightpanda-io/dupe-url-nav
fix slice alias crash after same document navigation
2026-02-04 08:25:32 +01:00
Karl Seguin
1352315441 Execute form.onsubmit when a form is being submitted 2026-02-04 13:45:53 +08:00
Karl Seguin
3c635532c4 Merge pull request #1426 from lightpanda-io/update_page_js
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Update page.js based on the current context.
2026-02-04 08:51:53 +08:00
Karl Seguin
f8703bf884 Merge pull request #1459 from lightpanda-io/browser_context_arenas
Add a dedicated browser_context and page_arena to CDP.
2026-02-04 07:51:35 +08:00
Karl Seguin
eea3aa7a27 Merge pull request #1457 from lightpanda-io/arena_pool_free_list_len
Properly maintain the ArenaPool's free_list_len
2026-02-04 07:50:33 +08:00
Nikolay Govorov
6eff448508 Merge pull request #1464 from lightpanda-io/wp/mrdimidium/update-zig-v8-fork
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Use asan/tsan for building v8
2026-02-03 16:18:23 +00:00
Nikolay Govorov
eb8cac5980 Use asan/tsan for building v8 2026-02-03 16:01:18 +00:00
Karl Seguin
1a4086c98c de-duplicate context shutdown in isolated worl deinit 2026-02-03 23:04:44 +08:00
Karl Seguin
5c91076660 Merge pull request #1458 from lightpanda-io/tweaks
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
A few small tweaks
2026-02-03 20:09:38 +08:00
Karl Seguin
5467b8dd0d A few small tweaks
1 - Embed Page into Session, avoids having to allocate/deallocate the page
2 - Fix false-positive arena pool leak detection
3 - On form submit, pre-check if navigation will allowed before building the
    contents
4 - Make sure QueuedNavigation structure isn't use after page is removed
2026-02-03 20:07:47 +08:00
Karl Seguin
d46a9d6286 Merge pull request #1462 from lightpanda-io/check_for_leaks_after_all_releases
Only check for arena leaks AFTER we've triggered all releases
2026-02-03 18:43:40 +08:00
Karl Seguin
2fa7810128 Only check for arena leaks AFTER we've triggered all releases
We already did this for the Context, but shutting down the HTTP client can also
release resources.
2026-02-03 18:31:04 +08:00
Pierre Tachoire
8249725ae7 fix slice alias crash after same document navigation
The WPT tests navigation-api/navigation-methods/navigate-history-push-same-url.html
crashed with a @memcpy arguments alias error.

It seems to be due to the reuse of the previous page.url string.
Forcing to duplicate it fixes the crash.
2026-02-03 11:00:45 +01:00
Karl Seguin
c07b83335b add a few comments 2026-02-03 15:58:29 +08:00
Karl Seguin
7e575c501a Add a dedicated browser_context and page_arena to CDP.
The BrowserContext currently uses 3 arenas:
1 - Command-specific, which is like the call_arena, but for the processing of a
    single CDP command
2 - Notification-specific, which is similar, but for the processing of a single
    internal notification event
3 - Arena, which is just the session arena and lives for the duration of the
    BrowseContext/Session

This is pretty coarse and can results in significant memory accumulation if a
browser context is re-used for multiple navigations.

This commit introduces 3 changes:
1 - Rather than referencing Session.arena, the BrowerContext.arena is now its
    own arena. This doesn't really change anything, but it does help keep things
    a bit better separated.

2 - Introduces a page_arena (not to be confused with Page.arena). This arena
    exists for the duration of a 1 page, i.e. it's cleared when the
    BrowserContext receives the page_created internal notification. The
    `captured_responses` now uses this arena, which means captures only exist
    for the duration of the current page. This appears to be consistent with
    how chrome behaves (In fact, Chrome seems even more aggressive and doesn't
    appear to make any guarantees around captured responses). CDP refers to this
    lifetime as a "renderer" and has an experimental message, which we don't
    support, `Network.configureDurableMessages` to control this.

3 - Isolated Worlds are now more self contained with an arena from the ArenaPool.

There are currently 2 places where the BrowserContext.arena is still used:
1 - the isolated_world list
2 - the custom headers

Although this could be long lived, I believe the above is ok. We should just
really think twice whenever we want to use it for anything else.
2026-02-03 15:48:27 +08:00
Karl Seguin
933e2fb0ef Properly maintain the ArenaPool's free_list_len 2026-02-03 13:08:53 +08:00
Karl Seguin
8d51383fb2 Merge pull request #1450 from lightpanda-io/pluginarray_placeholder
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add Plugin and PluginArray placeholders
2026-02-03 07:10:46 +08:00
Karl Seguin
80f4c83b83 Merge pull request #1453 from lightpanda-io/xhr_arraybuffer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
add 'arraybuffer' responseType to XHR
2026-02-02 21:55:10 +08:00
Karl Seguin
0d739e4af7 Merge pull request #1446 from lightpanda-io/module_promise
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
When loading a module, make sure we have a module_promise
2026-02-02 18:29:50 +08:00
Karl Seguin
58f9027002 Merge pull request #1454 from lightpanda-io/element_replaceWith
implement Element.replaceWith
2026-02-02 17:56:25 +08:00
Karl Seguin
990f2e2892 Merge pull request #1455 from lightpanda-io/element_scrollIntoView
add dummy scrollIntoView
2026-02-02 17:56:07 +08:00
Karl Seguin
ce7989c171 add dummy scrollIntoView 2026-02-02 16:41:39 +08:00
Karl Seguin
4efb0229d4 implement Element.replaceWith 2026-02-02 16:04:59 +08:00
Karl Seguin
5dd6dc2d69 per-context scheduler 2026-02-02 12:40:54 +08:00
Karl Seguin
20931eb9d6 update page.js on context.deinit 2026-02-02 12:22:00 +08:00
Karl Seguin
c11fa122af Update page.js based on the current context.
page.js currently always references the page context. But through the inspector
JavaScript can be executed in different contexts. When we go from V8->Zig we
correctly capture the current context within the caller's Local. And, because of
this, mapping or anything else that happens against local.ctx, happens in the
right context.

EXCEPT...our code still accesses page.js. So you can have a v8->zig call
happening in Context-2, and our Zig call then tries to do something on Context-1
via page.js.

I'm introducing a change that updates page.js based on the current Caller and
restores it at the end of the Caller. This change is super small, but
potentially has major impact. It's hard to imagine that we haven't run into
problems with this before, and it's hard to imagine what problems this change
might introduce. Certainly, if anyone copies page.js, they'll be in for a rude
surprise, but i don't think we do that anywhere.
2026-02-02 12:22:00 +08:00
Karl Seguin
e9141c8300 Handle more partial-load states + fix possible dangling pointer.
==Fix 1==
The problem flow:
1 - The module is dynamically imported, this creates a cache entry with no
module and no module_promise, and starts an async fetch.

2 - Before dynamicModuleSourceCallback fires (from step 1 above), the same
module is imported as a child of a call graph, i.e. via resolveModuleCallback.
Here the module is compiled, but never evaluated (we only evaluate the root
module). This is where things start to go sour. Our cache entry now has a
module, but no module_promise.

3 - The async fetch completes and calls dynamicModuleSourceCallback which call
Context.module. This returns early (the module is already cached thanks to
step 2). But it then calls resolveDynamicModule which (a) has a module and (b)
no module_promise.

Our fix works because, if Context.module finds the cached module (from step 2),
it now also checks for the module_promise. If it doesn't find it, it evaluates
the module (which sets it).

I've since expanded the code to handle more intermediary states.

The original PR had:

if (gop.value_ptr.module_promise == null) {
    const mod = local.toLocal(cache_mod);
    if (mod.getStatus() == .kInstantiated) {
        return self.evaluateModule(want_result, mod, url, true);
    }
}

But now the code is:

if (gop.value_ptr.module_promise == null) {
    const mod = local.toLocal(cache_mod);
    if (mod.getStatus() == .kUninstantiated and try mod.instantiate(resolveModuleCallback) == false) {
        return error.ModuleInstantiationError;
    }
    return self.evaluateModule(want_result, mod, url, true);
}

It seems that v8 handles double-instantiation and double-evaluations safely.

Handle more partial-load states.
Handle more partial-load states + fix possible dangling pointer.

==Fix 2==
We were using `gop` after potentially writing to the map (via a nested call to
mod.evaluate()). We now re-fetch the map entry to be able to safely write to it
2026-02-02 12:12:21 +08:00
Karl Seguin
1d03b688d9 When loading a module, make sure we have a module_promise
Currently, when loading a module, if the module is found in the cache, it's
immediately returned. However, that can result in a timing issue where the
module is cached, but not evaluated, and doesn't have an associated promise.

This commit tries to ensure a module is always evaluated and that the cache
entry has a module promise.

This might fix an crash handler issue. I couldn't reproduce the issue though.
I believe it requires specific timing which is hard to reproduce in a test.
2026-02-02 11:13:59 +08:00
Karl Seguin
176d42f625 add 'arraybuffer' responseType to XHR 2026-02-02 07:45:21 +08:00
Karl Seguin
7c98a27c53 Merge pull request #1452 from lightpanda-io/css_escape
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
improve correctness of CSS.escape
2026-01-31 19:16:09 +08:00
Karl Seguin
020b30783e Merge pull request #1451 from lightpanda-io/xhr_fanalizer
Don't release XHR object until request complete
2026-01-31 19:15:58 +08:00
Pierre Tachoire
fafbdb0714 Merge pull request #1449 from lightpanda-io/scriptmanager-referer 2026-01-31 10:07:18 +01:00
Karl Seguin
466cdb4ee7 improve correctness of CSS.escape 2026-01-31 10:55:11 +08:00
Karl Seguin
fa66f0b509 Don't release XHR object until request complete
We previously figured that we could release the XHR object as soon as the JS
reference was out of scope. But the callbacks could still exist and thus the
XHR request should proceed.

This commit ensures the XHR instance remains valid so long as we have an active
request.

Might help with https://github.com/lightpanda-io/browser/issues/1448 but I can't
reliably reproduce this, so I'm not 100% sure it resolve the issue. That bug
appears to be caused by some timing interaction between the underlying HTTP
request and the v8 GC.
2026-01-31 10:31:16 +08:00
Karl Seguin
12a566c07e Merge pull request #1445 from lightpanda-io/schedule_task_finalizer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Allow [schedule] tasks to have finalizers
2026-01-31 07:06:24 +08:00
Karl Seguin
bf7a1c6b1f Merge pull request #1444 from lightpanda-io/inspector_rework
Rework Inspector usage
2026-01-31 07:06:11 +08:00
Karl Seguin
55891aa5f8 Add Plugin and PluginArray placeholders 2026-01-31 07:05:28 +08:00
Karl Seguin
7c0acd9fcb Merge pull request #1447 from lightpanda-io/exception_errors
Handle catching exception error objects
2026-01-31 06:41:37 +08:00
Pierre Tachoire
333f1e2c47 use page's headerForRequest with fetch and XHR 2026-01-30 18:18:20 +01:00
Pierre Tachoire
9d30cdfefc add HTTP headers referer for script manager requests 2026-01-30 18:16:33 +01:00
Karl Seguin
324f6fe16e Handle catching exception error objects
https://github.com/lightpanda-io/browser/issues/1443
2026-01-30 18:38:22 +08:00
Karl Seguin
5d96304332 Allow [schedule] tasks to have finalizers
There's no guarantee that a task will ever be run. A page can be shutdown by
the user or timeout or an error. Scheduler cleanup relies on the underlying
page.arena. This forces all tasks to rely on the page.arena as they have no way
to clean themselves.

This commit allows tasks to register a finalizer which is guaranteed to be
called when the scheduler is shutdown.

The window ScheduleCallback, PostMessageCallback now use an arena from the
ArenaPool rather than the page.arena and use the task finalizer to ensure the
arena is released on shutdown.
2026-01-30 17:23:03 +08:00
Pierre Tachoire
e6e32b5fd2 Merge pull request #1442 from lightpanda-io/disable_debug_crash_report
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
2026-01-30 10:14:01 +01:00
Karl Seguin
181f265de5 Rework Inspector usage
V8's inspector world is made up of 4 components: Inspector, Client, Channel and
Session. Currently, we treat all 4 components as a single unit which is tied to
the lifetime of CDP BrowserContext - or, loosely speaking, 1 "Inspector Unit"
per page / v8::Context.

According to https://web.archive.org/web/20210622022956/https://hyperandroid.com/2020/02/12/v8-inspector-from-an-embedder-standpoint/
and conversation with Gemini, it's more typical to have 1 inspector per isolate.
The general breakdown is the Inspector is the top-level manager, the Client is
our implementation which control how the Inspector works (its function we expose
that v8 calls into). These should be tied to the Isolate. Channels and Sessions
are more closely tied to Context, where the Channel is v8->zig and the Session
us zig->v8.

This PR does a few things
1 - It creates 1 Inspector and Client per Isolate (Env.js)
2 - It creates 1 Session/Channel per BrowserContext
3 - It merges v8::Session and v8::Channel into Inspector.Session
4 - It moves the Inspector instance directly into the Env
5 - BrowserContext interacts with the Inspector.Session, not the Inspector

4 is arguably unnecessary with respect to the main goal of this commit, but
the end-goal is to tighten the integration. Specifically, rather than CDP having
to inform the inspector that a context was created/destroyed, the Env which
manages Contexts directly (https://github.com/lightpanda-io/browser/pull/1432)
and which now has direct access to the Inspector, is now equipped to keep this
in sync.
2026-01-30 15:59:33 +08:00
Karl Seguin
e5fc8bb27c Disable crash report in debug
Crashing when developing is more noise than signal
2026-01-30 07:06:09 +08:00
Karl Seguin
34dda780d9 Merge pull request #1441 from lightpanda-io/set_attribute_to_string_api
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
fix setAttribute for new toString API
2026-01-30 07:05:29 +08:00
Karl Seguin
c7cf4eeb7a fix setAttribute for new toString API 2026-01-30 07:00:33 +08:00
Karl Seguin
a6e5d9f6dc Merge pull request #1439 from lightpanda-io/setAttribute-non-string
accept js.Value for element.setAttribute
2026-01-30 06:58:28 +08:00
Karl Seguin
ea1017584e Merge pull request #1433 from lightpanda-io/js_string
Cleanup js -> string
2026-01-30 06:55:40 +08:00
Karl Seguin
6aef32d7a8 Merge pull request #1438 from lightpanda-io/update_public_suffix_list
Update the public suffix list
2026-01-30 06:55:26 +08:00
Karl Seguin
4a1d71b6b8 Merge pull request #1437 from lightpanda-io/remove_unused
Remove unused import
2026-01-30 06:55:11 +08:00
Karl Seguin
a18b61cb1d Merge pull request #1432 from lightpanda-io/remove_execution_world
Remove js.ExecutionWorld
2026-01-30 06:54:55 +08:00
Karl Seguin
e31e19aeba Merge pull request #1431 from lightpanda-io/crash_handler_discord
add discord link to crash handler
2026-01-30 06:54:34 +08:00
Pierre Tachoire
ef6d8a6554 accept js.Value for element.setAttributeNS 2026-01-29 17:17:08 +01:00
Pierre Tachoire
100764d79e accept js.Value for element.setAttribute 2026-01-29 17:10:43 +01:00
Karl Seguin
75abe7da1b Update the public suffix list 2026-01-29 21:00:06 +08:00
Karl Seguin
a19a125aec Remove unused import
And a few unused functions
2026-01-29 19:44:10 +08:00
Karl Seguin
f02fc95958 Merge pull request #1435 from lightpanda-io/handle_invalid_attribute_functions
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
skip invalid attribute functions
2026-01-29 16:50:40 +08:00
Karl Seguin
175edca8c7 Handle invalid attribute functions 2026-01-29 16:26:27 +08:00
Pierre Tachoire
f1f0a66f41 Merge pull request #1434 from lightpanda-io/update-source-deps-v2
update build from source deps
2026-01-29 09:13:08 +01:00
Pierre Tachoire
496c6905af update build from source deps 2026-01-29 08:42:58 +01:00
Karl Seguin
c84106570f Cleanup js -> string
Converting a JS value to a string is a bit messy right now. There's duplication
between string helpers in js.Local, and what js.String and js.Value provide.

Now, all stringifying functions are in js.String, with some helpers in js.Value.

Also tried to streamline the APIs around most common-cases (e.g. js.String ->
[]u8 using call_arena). js.String now also implements format, so it can be
used as-is in some cases.
2026-01-29 14:45:09 +08:00
Karl Seguin
1a05da9e55 Remove js.ExecutionWorld
The ExecutionWorld doesn't do anything meaningful. It doesn't map to, or
abstract any, v8 concepts. It creates a js.Context, destroys the context and
points to the context. Those all all things the Env can do (and it isn't like
the Env is over-burdened as-is).

Plus the benefit of going through the Env is that we can track/collect all
known Contexts for an isolate in 1 place (the Env), which can facilitate things
like context creation/deletion notifications.
2026-01-29 11:22:01 +08:00
Halil Durak
232e7a1759 Merge pull request #1430 from lightpanda-io/nikneym/attr-event-listeners
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Support HTML inline event listeners
2026-01-29 02:18:37 +03:00
Karl Seguin
c440d41d57 Merge pull request #1427 from lightpanda-io/arena_pool_double_free_detect
Add double-free detection to ArenaPool (in Debug Mode)
2026-01-29 07:04:33 +08:00
Karl Seguin
dfe5c24404 remove unused import and unused export 2026-01-29 07:04:20 +08:00
Karl Seguin
eba5773d56 Merge pull request #1428 from lightpanda-io/parser_arena_pool
Use ArenaPool when parsing HTML and for TextDecoder (with finalizer)
2026-01-29 06:49:14 +08:00
Karl Seguin
5d56fea2d3 check for leak after context is removed, as that can cause finalizers to run 2026-01-29 06:47:55 +08:00
Karl Seguin
946f02b7a2 Add double-free detection to ArenaPool (in Debug Mode)
Double-freeing should eventually cause a segfault (on ArenaPool.deinit, if not
sooner), but having an explicit check allows us to log the responsible owner.
2026-01-29 06:46:18 +08:00
Karl Seguin
8e8ffd21d5 add discord link to crash handler 2026-01-29 06:45:35 +08:00
Pierre Tachoire
d02d974cd0 Merge pull request #1429 from lightpanda-io/update-required-deps
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
update required deps for build from sources
2026-01-28 17:49:59 +01:00
Halil Durak
0a68be695d add tests 2026-01-28 17:46:27 +03:00
Pierre Tachoire
335e781d0c update required deps for build from sources 2026-01-28 15:37:58 +01:00
Halil Durak
9f5c2e4ca7 add getter/setter functions for attribute event listeners
Spec say these belong to `HTMLElement`.
2026-01-28 17:28:16 +03:00
Halil Durak
76a53bedbe split inline event listener logic to Page.zig and Element.zig 2026-01-28 17:26:56 +03:00
Karl Seguin
b0bc84ed21 Merge pull request #1422 from lightpanda-io/log-on-call-err
always log try/catch error on call function
2026-01-28 18:45:02 +08:00
Pierre Tachoire
ae298fc2e6 use caught formatter and init caught into _tryCallWithThis 2026-01-28 11:27:05 +01:00
Pierre Tachoire
3b809b2910 Merge pull request #1421 from lightpanda-io/fix-context-collected
use inspector.resetContextGroup during cdp deinit
2026-01-28 11:22:49 +01:00
Pierre Tachoire
68fbc0bde3 use inspector.resetContextGroup during cdp deinit
Ensure the inspector is correctly reset from context before deinit it.
It fixes the contextCollected crash in a better way.
2026-01-28 11:11:38 +01:00
Pierre Tachoire
9d8e5263a6 Merge pull request #1418 from lightpanda-io/mem-pressure
use less aggressive v8 GC
2026-01-28 11:09:45 +01:00
Pierre Tachoire
7eb026cc0d update zig-v8 deps 2026-01-28 10:38:04 +01:00
Karl Seguin
e51e6aa2b0 Use ArenaPool when parsing HTML and for TextDecoder (with finalizer)
Slowly more page.arena -> ArenaPool wherever possible. In some cases, an arena
from the arenapool will be preferred over the call_arena also.
2026-01-28 14:44:05 +08:00
Karl Seguin
bc700d2044 Merge pull request #1424 from lightpanda-io/parser_append_existing_parent
Add defensiveness around Parser.appendCallback
2026-01-28 09:21:14 +08:00
Karl Seguin
30ed58ff07 fix build 2026-01-28 08:06:22 +08:00
Karl Seguin
066069baad Add defensiveness around Parser.appendCallback
We're seeing an assertion in Page.appendNew fail because the node has a parent.
According to html5ever, this shouldn't be possible (appendNew is only called
from the Parser). BUT, it's possible we're mutating the node in a way that
we shouldn't...maybe there's JavaScript executing as we're parsing which is
mutating the node.

In release, this will be more defensive. In debug, this still crashes. It's
possible this is valid (like I said, maybe there's JS interleaved which is
mutating the node), but if so, I'd like to know the exact scenario that produces
this case.
2026-01-28 07:33:04 +08:00
Karl Seguin
068ec68917 Merge pull request #1420 from lightpanda-io/resolve_fix
Handle URL.resolve with path traversal as part of the filename
2026-01-28 06:46:35 +08:00
Halil Durak
560f028bda remove unused getListenerType 2026-01-28 01:33:17 +03:00
Halil Durak
fd1e77df8f parse event listeners provided as attributes 2026-01-28 01:31:43 +03:00
Karl Seguin
864ac08f16 optimize this more 2026-01-28 06:17:52 +08:00
Halil Durak
6ad1a11593 catch pointer overflows in createLookupKey
Its better to have this; if this is incorrect, its better to get notified.
2026-01-27 23:52:12 +03:00
Halil Durak
89174ba0b6 EventManager: introduce inline_lookup
Idea with this is to have a key-to-function for known event listeners. We pack pointer to event target with listener type to generate key and set function as value. By doing this, we save bytes for optionally and rarely set functions in elements.
2026-01-27 23:37:46 +03:00
Pierre Tachoire
fc5496e570 always log try/catch error on call function
We force log of detailled error caught during function call.
2026-01-27 18:41:12 +01:00
Karl Seguin
fd21d952ac Handle URL.resolve with path traversal as part of the filename 2026-01-27 21:45:16 +08:00
Karl Seguin
073fea2bde Merge pull request #1419 from lightpanda-io/arena_pool_leak_track_use_after_free
Reset _arena_pool_leak_track after the page.arena is reset
2026-01-27 18:04:46 +08:00
Karl Seguin
e548712f5e Reset _arena_pool_leak_track after the page.arena is reset 2026-01-27 17:55:32 +08:00
Pierre Tachoire
c3ba83ff93 use less aggressive v8 GC
Isolate.lowMemoryNotification runs an aggrissive GC.
Using Isolate.memoryPressureNotification allow a more granular control
of GC.
2026-01-27 09:39:08 +01:00
Karl Seguin
451dd0fd64 Merge pull request #1411 from lightpanda-io/more_SSO
more small strings (string.String)
2026-01-27 13:18:56 +08:00
Karl Seguin
aa805c2428 Merge pull request #1415 from lightpanda-io/dynamic_module_import
Improve dynamic module loading
2026-01-27 13:09:13 +08:00
Karl Seguin
58a7590aff Merge pull request #1416 from lightpanda-io/zigfmt
zig fmt
2026-01-27 13:08:54 +08:00
Karl Seguin
563ab30564 Merge pull request #1412 from lightpanda-io/response_arena
Add finalizer to Response and use an pooled arena
2026-01-27 12:55:52 +08:00
Karl Seguin
5050b34361 zig fmt 2026-01-27 12:55:25 +08:00
Karl Seguin
3bb86f196b Improve dynamic module loading
We're seeing cases where known dynamic modules are being requested when the
module isn't compiled yet. It's not clear how this is happening. I believe an
empty cache entry is being created in postCompileModule and then the request
for the dynamic module is happening before the sycnhronous module is loaded.
This seems like the only way to get into this state ,but I can't reproduce it.

Still, we now try to handle this case by simply having the dynamic module
request overwrite the placeholder cache-entry created in postCompileModule.
2026-01-27 12:51:37 +08:00
Karl Seguin
51dca3be11 Merge pull request #1414 from lightpanda-io/cheak_leak_before_free
Check for arena pool leak _before_ resetting page arena
2026-01-27 08:55:31 +08:00
Karl Seguin
adeda6cd75 Merge pull request #1413 from lightpanda-io/always_trycatch_function_call
Always use TryCatch when calling a function
2026-01-27 08:55:20 +08:00
Karl Seguin
09665c3a4a Check for arena pool leak _before_ resetting page arena
Cherry-picked from 31c0ac33d82271ee70b48b209b74a578e5e5c019
2026-01-27 06:43:23 +08:00
Karl Seguin
8f5f6212d2 Always use TryCatch when calling a function
And always ensure caught is initialized.
2026-01-26 20:06:47 +08:00
Karl Seguin
a11ae912b4 Add finalizer to Response and use an pooled arena
Unlike XHR, Response is a bit more complicated as it can exist in Zig code
without ever being given to v8. So we need to track this handoff to know who is
responsible for freeing it (zig code, on error/shutdown) or v8 code after
promise resolution.

This also cleansup a bad merge for the XHR finalizer and adds cleaning up the
`XMLHttpRequestEventTarget` callbacks.
2026-01-26 19:18:32 +08:00
Karl Seguin
3b12240615 remove newString helper in favor of .wrap 2026-01-26 08:00:04 +08:00
Karl Seguin
862520e4b1 micro-optimize String.eql(String) 2026-01-26 07:52:27 +08:00
Karl Seguin
a3d2dd8366 Convert most Attribute related calls from []const u8 -> String 2026-01-26 07:52:27 +08:00
Karl Seguin
16ef487871 Make "Safe" variants of Attribute work on String 2026-01-26 07:52:27 +08:00
Karl Seguin
54c45a0cfd Make js.Bridge aware of string.String for input parameters
Avoids having to allocate small strings when going from v8 -> Zig. Also
added a discriminatory type, string.Global which uses the arena, rather than
the call_arena, if an allocation _is_ necessary. (This is similar to a feature
we had before, but was lost in zigdom). Strings from v8 that need to be
persisted, can be allocated directly v8 -> arena, rather than v8 -> call_arena
-> arena.

I think there are a lot of places where we should use string.String - where
strings are expected to be short (e.g. attribute names). But started with just
document.querySelector and querySelectorAll.
2026-01-26 07:52:27 +08:00
Karl Seguin
1f14eb62d4 Merge pull request #1410 from lightpanda-io/insertAdjacentHtml
Fix insertAdjacentHtml
2026-01-26 07:45:11 +08:00
Karl Seguin
0db86a8b3d Merge pull request #1396 from lightpanda-io/eager_global_reset
Start to eagerly reset globals.
2026-01-26 07:45:00 +08:00
Karl Seguin
c63c85071a Start to eagerly reset globals.
Currently, when you create a Global (Value, Object, Function, ...) it exists
until the context is destroyed.

This PR adds the ability to eagerly free them when they fall out of scope, which
is only possible because of the new finalizer hooks.

Previously, we had js.Value, js.Value.Global; js.Function, js.Function.Global,
etc. This PR introduces a .Temp variant: js.Value.Temp and js.Function.Temp.
This is purely a discriminatory type and it behaves (and IS) a Global. The
difference is that it can be released:

  page.js.release(self._on_ready_state_change.?)

Why a new type? There's no guarantee that a global (the existing .Global or the
new .Temp) will get released before the context ends. For this reason, we always
track them in order to free the on context deninit:

```zig
    for (self.global_functions.items) |*global| {
        v8.v8__Global__Reset(global);
    }
```

If a .Temp is eagerly released, we need to remove it from this list. The simple
solution would be to switch `global_functions` from an ArrayList to a HashMap.
But that adds overhead for values that we know we'll never be able to eagerly
release. For this reason, .Temp are stored in a hashmap (and can be released)
and .Globla are stored in an ArrayList (and cannot be released). It's a micro-
optimization...eagerly releasing doesn't have to O(N) scan the list, and we only
pay the memory overhead of the hashmap for values that have a change to be
eagerly freed.

Eager-freeing is now applied to both the callbacn and the values for window
timers (setTimeout, setInterval, RAF). And to the XHR ready_state_change
callback. (we'll do more as we go).
2026-01-26 07:39:05 +08:00
Karl Seguin
b63d93e325 Add XHR finalizer and ArenaPool
Any object we return from Zig to V8 becomes a v8::Global that we track in our
`ctx.identity_map`. V8 will not free such objects. On the flip side, on its own,
our Zig code never knows if the underlying v8::Object of a global can still be
used from JS. Imagine an XHR request where we fire the last readyStateChange
event..we might think we no longer need that XHR instance, but nothing stops
the JavaScript code from holding a reference to it and calling a property on it,
e.g. `xhr.status`.

What we can do is tell v8 that we're done with the global and register a callback.
We make our reference to the global weak. When v8 determines that this object
cannot be reached from JavaScript, it _may_ call our registered callback. We can
then clean things up on our side and free the global (we actually _have_ to
free the global).

v8 makes no guarantee that our callback will ever be called, so we need to track
these finalizable objects and free them ourselves on context shutdown. Furthermore
there appears to be some possible timing issues, especially during context shutdown,
so we need to be defensive and make sure we don't double-free (we can use the
existing identity_map for this).

An type like XMLHttpRequest can be re-used. After a request succeeds or fails,
it can be re-opened and a new request sent. So we also need a way to revert a
"weak" reference back into a "strong" reference. These are simple v8 calls on
the v8::Global, but it highlights how sensitive all this is. We need to mark
it as weak when we're 100% sure we're done with it, and we need to switch it to
strong under any circumstances where we might need it again on our side.

Finally, none of this makes sense if there isn't something to free. Of course,
the finalizer lets us release the v8::Global, and we can free the memory for the
object itself (i.e. the `*XMLHttpRequest`). This PR also adds an ArenaPool. This
allows the XMLHTTPRequest to be self-contained and not need the `page.arena`.
On init, the `XMLHTTPRequest` acquires an arena from the pool. On finalization
it releases it back to the pool. So we now have:

- page.call_arena: short, guaranteed for 1 v8 -> zig -> v8 flow
- page.arena long: lives for the duration of the entire page
- page.arena_pool: ideally lives for as long as needed by its instance (but no
guarantees from v8 about this, or the script might leak a lot of global, so worst
case, same as page.arena)
2026-01-26 07:38:24 +08:00
Karl Seguin
12c6e50e16 Merge pull request #1383 from lightpanda-io/xhr_finalizer
Add XHR finalizer and ArenaPool
2026-01-26 07:34:23 +08:00
Karl Seguin
53ccc2e04c Merge pull request #1404 from lightpanda-io/crash_handler_stack_trace
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Capture the stack trace on the crash handler report
2026-01-24 18:29:18 +08:00
Karl Seguin
2d3234b54d Fix insertAdjacentHtml
Handle a wider range of HTML, including invalid html, empty string, head only
comment only, etc..
2026-01-24 10:04:19 +08:00
Karl Seguin
9a57c2a0d4 fix merge 2026-01-24 08:28:26 +08:00
Karl Seguin
fc64abee8f Add finalizer mode
When a type is finalized by V8, it's because it's fallen out of scope. When a
type is finalized by Zig, it's because the Context is being shutdown.

Those are two different environments and might require distinct cleanup logic.
Specifically, a zig-initiated finalization needs to consider that the page and
context are being shutdown. It isn't necessarily safe to execute JavaScript at
this point, and thus, not safe to execute a callback (on_error, on_abort,
ready_state_change, ...).
2026-01-24 07:59:43 +08:00
Karl Seguin
d5f26f6d15 remove temp variable 2026-01-24 07:59:43 +08:00
Karl Seguin
97f9c2991b on XHR shutdown, use terminate to prevent any client callbacks into the XHR 2026-01-24 07:59:43 +08:00
Karl Seguin
81378d4353 Simplify XHR lifetime
Keep this a weak reference (the default now).And rely on transfer abort to
ensure reference isn't needed after finalizer.
2026-01-24 07:59:43 +08:00
Karl Seguin
9f0c902030 more explicit arena pool debug parameter 2026-01-24 07:59:43 +08:00
Karl Seguin
3c0c75be10 Add XHR finalizer and ArenaPool
Any object we return from Zig to V8 becomes a v8::Global that we track in our
`ctx.identity_map`. V8 will not free such objects. On the flip side, on its own,
our Zig code never knows if the underlying v8::Object of a global can still be
used from JS. Imagine an XHR request where we fire the last readyStateChange
event..we might think we no longer need that XHR instance, but nothing stops
the JavaScript code from holding a reference to it and calling a property on it,
e.g. `xhr.status`.

What we can do is tell v8 that we're done with the global and register a callback.
We make our reference to the global weak. When v8 determines that this object
cannot be reached from JavaScript, it _may_ call our registered callback. We can
then clean things up on our side and free the global (we actually _have_ to
free the global).

v8 makes no guarantee that our callback will ever be called, so we need to track
these finalizable objects and free them ourselves on context shutdown. Furthermore
there appears to be some possible timing issues, especially during context shutdown,
so we need to be defensive and make sure we don't double-free (we can use the
existing identity_map for this).

An type like XMLHttpRequest can be re-used. After a request succeeds or fails,
it can be re-opened and a new request sent. So we also need a way to revert a
"weak" reference back into a "strong" reference. These are simple v8 calls on
the v8::Global, but it highlights how sensitive all this is. We need to mark
it as weak when we're 100% sure we're done with it, and we need to switch it to
strong under any circumstances where we might need it again on our side.

Finally, none of this makes sense if there isn't something to free. Of course,
the finalizer lets us release the v8::Global, and we can free the memory for the
object itself (i.e. the `*XMLHttpRequest`). This PR also adds an ArenaPool. This
allows the XMLHTTPRequest to be self-contained and not need the `page.arena`.
On init, the `XMLHTTPRequest` acquires an arena from the pool. On finalization
it releases it back to the pool. So we now have:

- page.call_arena: short, guaranteed for 1 v8 -> zig -> v8 flow
- page.arena long: lives for the duration of the entire page
- page.arena_pool: ideally lives for as long as needed by its instance (but no
guarantees from v8 about this, or the script might leak a lot of global, so worst
case, same as page.arena)
2026-01-24 07:59:41 +08:00
Karl Seguin
90d23abe18 fix null-byte 2026-01-24 07:58:36 +08:00
Karl Seguin
82eccf36d4 Merge pull request #1408 from lightpanda-io/fix-inspector-ctx-collected-crash
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
fix use after free during inspector contextCollected
2026-01-24 07:52:29 +08:00
Karl Seguin
342cb52887 Merge pull request #1409 from lightpanda-io/cleanup
zig fmt, remove unused code
2026-01-24 07:49:25 +08:00
Karl Seguin
cafa4f5173 correct crash report host 2026-01-24 07:46:14 +08:00
Karl Seguin
67cff5af8b zig fmt 2026-01-24 07:46:14 +08:00
Karl Seguin
6d23d91aa5 Capture the stack trace on the crash handler report 2026-01-24 07:46:13 +08:00
Karl Seguin
3a0699fc1d Merge pull request #1405 from lightpanda-io/prevent_fast_double_navigate
Handle fast double navigate
2026-01-24 07:39:39 +08:00
Karl Seguin
027e569087 Merge pull request #1398 from lightpanda-io/handle_non_200_scripts
Handle scripts that don't return a 200 status code
2026-01-24 07:39:19 +08:00
Karl Seguin
830f759f0b zig fmt, remove unused code 2026-01-24 07:37:30 +08:00
Pierre Tachoire
969891c71c fix use after free during inspector contextCollected
This commit fix the use after free crash into inspector contextCollected
run in the pumpMessageLoop.

Removing a context linked to an inspector triggers a contextCollected
task in the message queue.
But if the contextCollected task run after the GC it try to use free
memory. Forcing the message loop to run before the GC fix the issue.
2026-01-23 20:07:49 +01:00
Pierre Tachoire
4eb5c3e907 Merge pull request #1399 from alexisbouchez/window-onerror
Some checks failed
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
invoke window.onerror callback in reportError
2026-01-23 12:36:32 +01:00
Karl Seguin
23303a759b Prevents fast double navigate
This puppeteer script [likely will] crash the brower:

page.goto(baseURL + '/campfire-commerce/');
await page.goto(baseURL + '/campfire-commerce/');

The quick double-navigation means parse_state remains .pre and thus the page
isn't reset. We introduce a new load_state, .waiting, which can be used to
detect this state.
2026-01-23 19:09:36 +08:00
Karl Seguin
d1e7f46994 Merge pull request #1402 from lightpanda-io/defensive_local_scopes
Some checks failed
e2e-test / zig build release (push) Waiting to run
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
Explicitly creates LocalScope in hard-to-reason callsites
2026-01-23 18:59:45 +08:00
Karl Seguin
65ea70ae90 Merge pull request #1403 from lightpanda-io/module_async_import_self
Support a module dynamically importing itself
2026-01-23 18:59:26 +08:00
Karl Seguin
7522b71c86 Support a module dynamically importing itself 2026-01-23 10:45:41 +08:00
Karl Seguin
70625c86c3 Explicitly creates LocalScope in hard-to-reason callsites
In some cases, it's straightforward to know whether or not there's an implicit
local scope available. But both Navigation and ReadableStream* have more complex
flows. Both could, in theory, be initiated from non-V8 calls, so relying on
the implicit scope isn't safe.

This adds an explicit scope to most callbacks in Navigation and ReadbleStream*.
However, I still don't quite understand how / if these are being initiated from
Zig currently (I could see how they would be, in the future). Therefore, in
debug mode, this will still panic if there's no implicit scope, because I want
to understand what's going on.
2026-01-23 09:58:36 +08:00
Alexis Bouchez
74354d2027 invoke window.onerror callback in reportError 2026-01-22 10:12:06 +01:00
Karl Seguin
f6397e2731 Handle scripts that don't return a 200 status code
This was already being handled for async scripts, but for sync scripts, we'd
log the error then proceed to try and execute the body (which would be some
error message).

This allows the header_callback to return a boolean to indicate whether or not
the http client should continue to process the request or abort it.
2026-01-22 14:15:00 +08:00
Karl Seguin
065ca39d60 Merge pull request #1397 from lightpanda-io/heapprofiler
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Ability to capture a V8 heap profile and a heap snapshot
2026-01-22 12:17:47 +08:00
Karl Seguin
b4759ae261 Ability to capture a V8 heap profile and a heap snapshot 2026-01-22 10:27:58 +08:00
Karl Seguin
c095950ef9 Merge pull request #1395 from lightpanda-io/microtasks_on_local
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Move runMicrotask from Context to Local
2026-01-22 08:37:56 +08:00
Karl Seguin
24b7035b1b Merge pull request #1394 from lightpanda-io/avoid_double_doctype
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
On dump, use the HTMLDocument's doctype if available
2026-01-21 18:21:43 +08:00
Karl Seguin
7b1f157cf8 Merge pull request #1392 from lightpanda-io/handle_scope_for_message_loop
Create HandleScope for PumpMessageLoop
2026-01-21 15:44:14 +08:00
Karl Seguin
8b8bee4e9c Move runMicrotask from Context to Local
This ensures that there's always a HandleScope avaialble when running microtasks
2026-01-21 15:40:32 +08:00
Pierre Tachoire
c27ab35600 Merge pull request #1393 from lightpanda-io/remove_js_obj_cache
Disable JS object cache
2026-01-21 08:14:24 +01:00
Karl Seguin
446b4dc461 On dump, use the HTMLDocument's doctype if available
We currently force-write a simple HTML doctype for HTMLDocument. But if the
document already has a doctype, that results in us writing the forced one then
writing the correct one. This adds a check and only force-writes a doctype if
the first child of the document isn't a document_type node.
2026-01-21 14:15:11 +08:00
Karl Seguin
ff8ed24622 Merge pull request #1391 from lightpanda-io/lowmem-on-page-reset
call env.lowMemoryNotification() during page reset
2026-01-21 13:23:24 +08:00
Karl Seguin
ae2d6a122b Disable JS object cache
Was added here 43805ad698

But causes segfaults. The issue is hard to understand. At first, it seemed like
the value cached in a v8::Object was persisting through v8::contexts of the
same isolate. Set window.document to the current &document, and in a different
context, it retrieves that cached value (which is now an invalid pointers).

However, upon further investigation, this appears to be limited to a mix of
navigation (which causes a new context to be created, and old values to be
invalidated) + Inspector which continues to send commands to the old context.

Since contextDestroyed is something we're aware of and planning to do shortly,
I think we can disable the cache until that's fixed.
2026-01-21 11:26:59 +08:00
Karl Seguin
3cac375f21 Merge pull request #1386 from lightpanda-io/tweak_global_setup
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Move global setup to the Env (Isolate)
2026-01-21 07:11:47 +08:00
Karl Seguin
7d806dd161 Merge pull request #1369 from lightpanda-io/selection-webapi
`Selection` WebAPI
2026-01-21 07:11:11 +08:00
Karl Seguin
db037c704e Merge pull request #1388 from lightpanda-io/pointer_event
add PointerEvent
2026-01-21 07:10:32 +08:00
Karl Seguin
954184f742 Create HandleScope for PumpMessageLoop 2026-01-21 07:05:59 +08:00
Muki Kiboigo
7650e0b61a fix selection start updating to new len 2026-01-20 11:25:04 -08:00
Muki Kiboigo
4a5c93988f fix selection test expectation 2026-01-20 11:24:50 -08:00
Pierre Tachoire
8ceaf0ac66 call env.lowMemoryNotification() during page reset
calling env.lowMemoryNotification() on page reset encourages v8 to free
memory and keep low usage.
2026-01-20 18:27:25 +01:00
Pierre Tachoire
ca60aa1cc6 Merge pull request #1387 from lightpanda-io/lower_perf_regression
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-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
Reduce perf regression max
2026-01-20 13:36:16 +01:00
Karl Seguin
596d5906a0 add PointerEvent 2026-01-20 18:38:03 +08:00
Karl Seguin
c02db94522 Reduce perf regression max
Mem 28MB -> 26MB  (currently a 24.1MB)
Time 23 -> 17  (currently at 14)

We've made some memory and performance optimization gains lately. Lowering
these will let us spot incremental changes better.
2026-01-20 18:07:28 +08:00
Karl Seguin
3970803575 Merge pull request #1382 from lightpanda-io/cached_properties
Re-enable cached property support
2026-01-20 18:00:19 +08:00
Karl Seguin
43805ad698 Re-enable cached property support
The idea is that frequently accessed properties, e.g. window.document, can be
cached directly as data properties on the underlying v8::Object, removing the
need for the access call into Zig. This is only used on a handful of properties,
almost all of which are on the Window. It is important that the property be
read-only. For example, window.location cannot be cached this way because
window.location is writable (e.g. window.location.hash = '#blah').

This existed briefly before Zigdom, but was removed as part of the migration.
The implementation has changed. This previously relied on a "postAttach" feature
which no longer exists. It is not integrated in the bridge/callback directly and
lazily applied after the first access.
2026-01-20 17:34:52 +08:00
Karl Seguin
2498e12f19 Move global setup to the Env (Isolate)
Previously, we were doing some global setup in the Snapshot, but this was
always being overwritten when creating a context. This meaningless setup in
the snapshot was removed.

The global setup is now done once per isolate, rather than once per context.
2026-01-20 17:21:45 +08:00
Karl Seguin
6f3cb4b48e Merge pull request #1385 from lightpanda-io/remove_debug_print
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Remove a debug print
2026-01-20 16:36:26 +08:00
Karl Seguin
fbd047599e Merge pull request #1374 from lightpanda-io/fix_context_lifetime
Fix context lifetime
2026-01-20 16:24:12 +08:00
Karl Seguin
da00117622 Remove a debug print 2026-01-20 16:23:22 +08:00
Karl Seguin
e44c73bdf6 Merge pull request #1384 from lightpanda-io/htmlscript-src-absolute
`HTMLScriptElement` should return an absolute URL in `src`
2026-01-20 12:38:42 +08:00
Karl Seguin
e3cb7bd9f0 add test 2026-01-20 11:14:20 +08:00
Muki Kiboigo
08f5889ee5 getSrc should return an absolute URL 2026-01-19 18:50:24 -08:00
Muki Kiboigo
d5bfe74e1a add selection api to HTMLTextAreaElement 2026-01-19 18:37:52 -08:00
Muki Kiboigo
d7015fa3b6 add selection api to HTMLInputElement 2026-01-19 18:34:02 -08:00
Karl Seguin
9092651b5b Merge branch 'main' into fix_context_lifetime 2026-01-20 08:50:41 +08:00
Karl Seguin
2c53b48e0a add missing handlescope 2026-01-20 08:11:38 +08:00
Muki Kiboigo
319a1c3367 update WPT to include Selection 2026-01-19 07:12:40 -08:00
Muki Kiboigo
80dd590e8f add toString to Selection 2026-01-19 07:12:40 -08:00
Muki Kiboigo
992a8e8774 handle null anchor or focus nodes in Selection 2026-01-19 07:12:40 -08:00
Muki Kiboigo
f56d3bd193 do not modify old range in collapseToX 2026-01-19 07:12:40 -08:00
Muki Kiboigo
4ecc59d0c0 Fix a lot of Selection Issues
This uses the Chrome/Safari approach of only
having a single Range exist in the Selection.
This also better follows the W3C spec of Selection
2026-01-19 07:12:40 -08:00
Muki Kiboigo
5ebf82874b fix selection test inconsistency 2026-01-19 07:12:40 -08:00
Muki Kiboigo
12670a3153 fix extend direction in Selection 2026-01-19 07:12:40 -08:00
Muki Kiboigo
fa3a23134e properly return NotFoundError on removeRange 2026-01-19 07:12:39 -08:00
Muki Kiboigo
8291044abc fix collapseToStart on Selection 2026-01-19 07:12:39 -08:00
Muki Kiboigo
505e0799da add remaining functions to Selection 2026-01-19 07:12:39 -08:00
Muki Kiboigo
be1d463775 add Selection WebAPI test 2026-01-19 07:12:39 -08:00
Muki Kiboigo
a6fc5aa345 add getSelection to Window, Document 2026-01-19 07:12:37 -08:00
Muki Kiboigo
0e6e4db08b add Selection WebAPI 2026-01-19 07:11:45 -08:00
Karl Seguin
0edc1fcec7 fix rebase + migrate SubtleCrypto to new local 2026-01-19 07:36:14 +08:00
Karl Seguin
b46d3b22e2 Remove unnecessary handlescope
There's one _always_ created immediately before it.
2026-01-19 07:28:57 +08:00
Karl Seguin
412c881cd4 fix wpt and legacy_test runners 2026-01-19 07:28:56 +08:00
Karl Seguin
48f07a110f fix bad great rebase 2026-01-19 07:28:35 +08:00
Karl Seguin
5c1b7935e2 remove global handlescope 2026-01-19 07:28:35 +08:00
Karl Seguin
62aa564df1 Remove Global v8::Local<V8::Context>
When we create a js.Context, we create the underlying v8.Context and store it
for the duration of the page lifetime. This works because we have a global
HandleScope - the v8.Context (which is really a v8::Local<v8::Context>) is that
to the global HandleScope, effectively making it a global.

If we want to remove our global HandleScope, then we can no longer pin the
v8.Context in our js.Context. Our js.Context now only holds a v8.Global of the
v8.Context (v8::Global<v8::Context).

This PR introduces a new type, js.Local, which takes over a lot of the
functionality previously found in either js.Caller or js.Context. The simplest
way to think about it is:

1 - For v8 -> zig calls, we create a js.Caller (as always)
2 - For zig -> v8 calls, we go through the js.Context (as always)
3 - The shared functionality, which works on a v8.Context, now belongs to js.Local

For #1 (v8 -> zig), creating a js.Local for a js.Caller is really simple and
centralized. v8 largely gives us everything we need from the
FunctionCallbackInfo or PropertyCallbackInfo.  For #2, it's messier, because we
can only create a local v8::Context if we have a HandleScope, which we may or
may not.

Unfortunately, in many cases, what to do becomes the responsibility of the caller
and much of the code has to become aware of this local-ness. What does it means
for our code? The impact is on WebAPIs that store .Global. Because the global
can't do anything. You always need to convert that .Global to a local
(e.g. js.Function.Global -> js.Function).

If you're 100% sure the WebAPI is only being invoked by a v8 callback, you can
use `page.js.local.?.toLocal(some_global).call(...)` to get the local value.

If you're 100% sure the WebAPI is only being invoked by Zig, you need to create
 `js.Local.Scope` to get access to a local:

```zig
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
ls.toLocal(some_global).call(...)
// can also access `&ls.local` for APIs that require a *const js.Local
```
For functions that can be invoked by either V8 or Zig, you should generally push
the responsibility to the caller by accepting a `local: *const js.Local`. If the
caller is a v8 callback, it can pass `page.js.local.?`. If the caller is a Zig
callback, it can create a `Local.Scope`.

As an alternative, it is possible to simply pass the *Page, and check
`if page.js.local == null` and, if so, create a Local.Scope. But this should only
be done for performance reasons. We currently only do this in 1 place, and it's
because the Zig caller doesn't know whether a Local will actually be needed and
it's potentially called on every element creating from the parser.
2026-01-19 07:28:33 +08:00
Karl Seguin
798ee4a4d5 Make js.Object and js.Value have explicit global
See: bb06900b6f84abaccc7ecfd386af1a9dc0029c50 for details on this change.
2026-01-19 07:27:03 +08:00
Karl Seguin
7d87fb80ec Make Global Function explicit.
This is the first in a series of changes to make globals explicit. The ultimate
goal of having explicit Globals is to move away from the global HandleScope and
to explicit HandleScopes.

Currently, we treat globals and locals interchangeably. In fact, for Global ->
Local, we just ptrCast. This works because we have 1 global HandleScope, which
effectively disables V8's GC and thus nothing ever gets moved.

If we're going to introduce explicit HandleScopes, then we need to first have
correct Globals. Specifically, when we want to act on the global, we need to
get the local value, and that will eventually mean making sure there's a
HandleScope.

While adding explicit globals, we're keeping the global HandleScope so that we
can minimize the change. So, given that we still have the global HandleScope
the change is largely two things:
1 - js.Function.persit() returns a js.Function.Global. Types that persist global
   functions must be updated to js.Function.Global.
2 - To turn js.Function.Global -> js.Function, we need to call .local() on it.

The bridge has been updated to support js.Function.Global for both input and
output parameters. Thus, window.setOnLoad can now directly take a
js.Function.Global, and window.getOnLoad can directly return that
js.Function.Global.
2026-01-19 07:26:33 +08:00
206 changed files with 14702 additions and 5918 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.2.4'
default: 'v0.2.8'
v8:
description: 'v8 version to install'
required: false
@@ -22,6 +22,10 @@ inputs:
description: 'cache dir to use'
required: false
default: '~/.cache'
debug:
description: 'enable v8 pre-built debug version, only available for linux x86_64'
required: false
default: 'false'
runs:
using: "composite"
@@ -32,7 +36,7 @@ runs:
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y wget xz-utils python3 ca-certificates git pkg-config libglib2.0-dev gperf libexpat1-dev cmake clang
sudo apt-get install -y wget xz-utils ca-certificates clang make git
# Zig version used from the `minimum_zig_version` field in build.zig.zon
- uses: mlugg/setup-zig@v2
@@ -47,17 +51,17 @@ runs:
cache-name: cache-v8
with:
path: ${{ inputs.cache-dir }}/v8
key: libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}_${{ inputs.zig-v8 }}.a
key: libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}_${{ inputs.zig-v8 }}${{inputs.debug == 'true' && '_debug' || '' }}.a
- if: ${{ steps.cache-v8.outputs.cache-hit != 'true' }}
shell: bash
run: |
mkdir -p ${{ inputs.cache-dir }}/v8
wget -O ${{ inputs.cache-dir }}/v8/libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${{ inputs.zig-v8 }}/libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}.a
wget -O ${{ inputs.cache-dir }}/v8/libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${{ inputs.zig-v8 }}/libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}${{inputs.debug == 'true' && '_debug' || '' }}.a
- name: install v8
shell: bash
run: |
mkdir -p v8
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/libc_v8.a
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/libc_v8${{inputs.debug == 'true' && '_debug' || '' }}.a

View File

@@ -40,7 +40,6 @@ jobs:
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
mode: 'release'
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
@@ -83,7 +82,6 @@ jobs:
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
mode: 'release'
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
@@ -128,7 +126,6 @@ jobs:
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
mode: 'release'
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
@@ -171,7 +168,6 @@ jobs:
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
mode: 'release'
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin

View File

@@ -56,8 +56,6 @@ jobs:
submodules: recursive
- uses: ./.github/actions/install
with:
mode: 'release'
- name: zig build release
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
@@ -124,8 +122,8 @@ jobs:
needs: zig-build-release
env:
MAX_MEMORY: 28000
MAX_AVG_DURATION: 23
MAX_MEMORY: 26000
MAX_AVG_DURATION: 17
LIGHTPANDA_DISABLE_TELEMETRY: true
# use a self host runner.

View File

@@ -12,8 +12,7 @@ on:
- main
paths:
- "build.zig"
- "src/**/*.zig"
- "src/*.zig"
- "src/**"
- "vendor/zig-js-runtime"
- ".github/**"
- "vendor/**"
@@ -38,6 +37,26 @@ on:
workflow_dispatch:
jobs:
zig-test-debug:
name: zig test using v8 in debug mode
timeout-minutes: 15
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
with:
debug: true
- name: zig build test
run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test
zig-test:
name: zig test
timeout-minutes: 15

View File

@@ -3,11 +3,12 @@ FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4
ARG ZIG_V8=v0.2.4
ARG ZIG_V8=v0.2.8
ARG TARGETPLATFORM
RUN apt-get update -yq && \
apt-get install -yq xz-utils ca-certificates \
pkg-config libglib2.0-dev \
clang make curl git
# Get Rust

View File

@@ -78,23 +78,49 @@ docker run -d --name lightpanda -p 9222:9222 lightpanda/browser:nightly
### Dump a URL
```console
./lightpanda fetch --dump https://lightpanda.io
./lightpanda fetch --obey_robots --log_format pretty --log_level info https://demo-browser.lightpanda.io/campfire-commerce/
```
```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')
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
disabled = false
INFO page : navigate . . . . . . . . . . . . . . . . . . . . [+6ms]
url = https://demo-browser.lightpanda.io/campfire-commerce/
method = GET
reason = address_bar
body = false
req_id = 1
INFO browser : executing script . . . . . . . . . . . . . . [+118ms]
src = https://demo-browser.lightpanda.io/campfire-commerce/script.js
kind = javascript
cacheable = true
INFO http : request complete . . . . . . . . . . . . . . . . [+140ms]
source = xhr
url = https://demo-browser.lightpanda.io/campfire-commerce/json/product.json
status = 200
len = 4770
INFO http : request complete . . . . . . . . . . . . . . . . [+141ms]
source = fetch
url = https://demo-browser.lightpanda.io/campfire-commerce/json/reviews.json
status = 200
len = 1615
<!DOCTYPE html>
```
### Start a CDP server
```console
./lightpanda serve --host 127.0.0.1 --port 9222
./lightpanda serve --obey_robots --log_format pretty --log_level info --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...
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
disabled = false
INFO app : server running . . . . . . . . . . . . . . . . . [+0ms]
address = 127.0.0.1:9222
```
Once the CDP server started, you can run a Puppeteer script by configuring the
@@ -115,7 +141,7 @@ const context = await browser.createBrowserContext();
const page = await context.newPage();
// Dump all the links from the page.
await page.goto('https://wikipedia.com/');
await page.goto('https://demo-browser.lightpanda.io/amiibo/', {waitUntil: "networkidle0"});
const links = await page.evaluate(() => {
return Array.from(document.querySelectorAll('a')).map(row => {
@@ -156,6 +182,7 @@ Here are the key features we have implemented:
- [x] Custom HTTP headers
- [x] Proxy support
- [x] Network interception
- [x] Respect `robots.txt` with option `--obey_robots`
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
@@ -178,6 +205,7 @@ For **Debian/Ubuntu based Linux**:
```
sudo apt install xz-utils ca-certificates \
pkg-config libglib2.0-dev \
clang make curl git
```
You also need to [install Rust](https://rust-lang.org/tools/install/).

View File

@@ -35,7 +35,8 @@ pub fn build(b: *Build) !void {
opts.addOption([]const u8, "git_commit", git_commit orelse "dev");
opts.addOption(?[]const u8, "snapshot_path", snapshot_path);
const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer");
const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false;
const enable_asan = b.option(bool, "asan", "Enable Address Sanitizer") orelse false;
const enable_csan = b.option(std.zig.SanitizeC, "csan", "Enable C Sanitizers");
const lightpanda_module = blk: {
@@ -50,7 +51,7 @@ pub fn build(b: *Build) !void {
});
mod.addImport("lightpanda", mod); // allow circular "lightpanda" import
try addDependencies(b, mod, opts, prebuilt_v8_path);
try addDependencies(b, mod, opts, enable_asan, enable_tsan, prebuilt_v8_path);
break :blk mod;
};
@@ -170,15 +171,25 @@ pub fn build(b: *Build) !void {
}
}
fn addDependencies(b: *Build, mod: *Build.Module, opts: *Build.Step.Options, prebuilt_v8_path: ?[]const u8) !void {
fn addDependencies(
b: *Build,
mod: *Build.Module,
opts: *Build.Step.Options,
is_asan: bool,
is_tsan: bool,
prebuilt_v8_path: ?[]const u8,
) !void {
mod.addImport("build_config", opts.createModule());
const target = mod.resolved_target.?;
const dep_opts = .{
.target = target,
.optimize = mod.optimize.?,
.prebuilt_v8_path = prebuilt_v8_path,
.cache_root = b.pathFromRoot(".lp-cache"),
.prebuilt_v8_path = prebuilt_v8_path,
.is_asan = is_asan,
.is_tsan = is_tsan,
.v8_enable_sandbox = is_tsan,
};
mod.addIncludePath(b.path("vendor/lightpanda"));

View File

@@ -6,10 +6,10 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/v0.2.4.tar.gz",
.hash = "v8-0.0.0-xddH66YvBAD0YI9xr6F0Xgnw9wN30FdZ10FLyuoV3e66",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.2.8.tar.gz",
.hash = "v8-0.0.0-xddH63lfBADJ7UE2zAJ8nJIBnxoSiimXSaX6Q_M_7DS3",
},
// .v8 = .{ .path = "../zig-v8-fork" },
//.v8 = .{ .path = "../zig-v8-fork" },
.@"boringssl-zig" = .{
.url = "git+https://github.com/Syndica/boringssl-zig.git#c53df00d06b02b755ad88bbf4d1202ed9687b096",
.hash = "boringssl-0.1.0-VtJeWehMAAA4RNnwRnzEvKcS9rjsR1QVRw1uJrwXxmVK",

24
flake.lock generated
View File

@@ -8,11 +8,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1763016383,
"narHash": "sha256-eYmo7FNvm3q08iROzwIi8i9dWuUbJJl3uLR3OLnSmdI=",
"lastModified": 1770708269,
"narHash": "sha256-OnZW86app7hHJJoB5lC9GNXY5QBBIESJB+sIdwEyld0=",
"owner": "nix-community",
"repo": "fenix",
"rev": "0fad5c0e5c531358e7174cd666af4608f08bc3ba",
"rev": "6b5325a017a9a9fe7e6252ccac3680cc7181cd63",
"type": "github"
},
"original": {
@@ -96,11 +96,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1763043403,
"narHash": "sha256-DgCTbHdIpzbXSlQlOZEWj8oPt2lrRMlSk03oIstvkVQ=",
"lastModified": 1768649915,
"narHash": "sha256-jc21hKogFnxU7KXSVTRmxC7u5D4RHwm9BAvDf5/Z1Uo=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "75e04ecd084f93d4105ce68c07dac7656291fe2e",
"rev": "3e3f3c7f9977dc123c23ee21e8085ed63daf8c37",
"type": "github"
},
"original": {
@@ -122,11 +122,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1762860488,
"narHash": "sha256-rMfWMCOo/pPefM2We0iMBLi2kLBAnYoB9thi4qS7uk4=",
"lastModified": 1770668050,
"narHash": "sha256-Q05yaIZtQrBKHpyWaPmyJmDRj0lojnVf8nUFE0vydcY=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "2efc80078029894eec0699f62ec8d5c1a56af763",
"rev": "9efc1f709f3c8134c3acac5d3592a8e4c184a0c6",
"type": "github"
},
"original": {
@@ -175,11 +175,11 @@
]
},
"locked": {
"lastModified": 1762907712,
"narHash": "sha256-VNW/+VYIg6N4b9Iq+F0YZmm22n74IdFS7hsPLblWuOY=",
"lastModified": 1770598090,
"narHash": "sha256-k+82IDgTd9o5sxHIqGlvfwseKln3Ejx1edGtDltuPXo=",
"owner": "mitchellh",
"repo": "zig-overlay",
"rev": "d16453ee78765e49527c56d23386cead799b6b53",
"rev": "142495696982c88edddc8e17e4da90d8164acadf",
"type": "github"
},
"original": {

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -21,66 +21,38 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const log = @import("log.zig");
const Http = @import("http/Http.zig");
const Config = @import("Config.zig");
const Snapshot = @import("browser/js/Snapshot.zig");
const Platform = @import("browser/js/Platform.zig");
const Notification = @import("Notification.zig");
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
const RobotStore = @import("browser/Robots.zig").RobotStore;
pub const Http = @import("http/Http.zig");
pub const ArenaPool = @import("ArenaPool.zig");
// Container for global state / objects that various parts of the system
// might need.
const App = @This();
http: Http,
config: Config,
config: *const Config,
platform: Platform,
snapshot: Snapshot,
telemetry: Telemetry,
allocator: Allocator,
arena_pool: ArenaPool,
robots: RobotStore,
app_dir_path: ?[]const u8,
notification: *Notification,
shutdown: bool = false,
pub const RunMode = enum {
help,
fetch,
serve,
version,
};
pub const Config = struct {
run_mode: RunMode,
tls_verify_host: bool = true,
http_proxy: ?[:0]const u8 = null,
proxy_bearer_token: ?[:0]const u8 = null,
http_timeout_ms: ?u31 = null,
http_connect_timeout_ms: ?u31 = null,
http_max_host_open: ?u8 = null,
http_max_concurrent: ?u8 = null,
user_agent: [:0]const u8,
};
pub fn init(allocator: Allocator, config: Config) !*App {
pub fn init(allocator: Allocator, config: *const Config) !*App {
const app = try allocator.create(App);
errdefer allocator.destroy(app);
app.config = config;
app.allocator = allocator;
app.notification = try Notification.init(allocator, null);
errdefer app.notification.deinit();
app.robots = RobotStore.init(allocator);
app.http = try Http.init(allocator, .{
.max_host_open = config.http_max_host_open orelse 4,
.max_concurrent = config.http_max_concurrent orelse 10,
.timeout_ms = config.http_timeout_ms orelse 5000,
.connect_timeout_ms = config.http_connect_timeout_ms orelse 0,
.http_proxy = config.http_proxy,
.tls_verify_host = config.tls_verify_host,
.proxy_bearer_token = config.proxy_bearer_token,
.user_agent = config.user_agent,
});
app.http = try Http.init(allocator, &app.robots, config);
errdefer app.http.deinit();
app.platform = try Platform.init();
@@ -91,10 +63,11 @@ pub fn init(allocator: Allocator, config: Config) !*App {
app.app_dir_path = getAndMakeAppDir(allocator);
app.telemetry = try Telemetry.init(app, config.run_mode);
app.telemetry = try Telemetry.init(app, config.mode);
errdefer app.telemetry.deinit();
try app.telemetry.register(app.notification);
app.arena_pool = ArenaPool.init(allocator);
errdefer app.arena_pool.deinit();
return app;
}
@@ -110,10 +83,11 @@ pub fn deinit(self: *App) void {
self.app_dir_path = null;
}
self.telemetry.deinit();
self.notification.deinit();
self.robots.deinit();
self.http.deinit();
self.snapshot.deinit();
self.platform.deinit();
self.arena_pool.deinit();
allocator.destroy(self);
}

87
src/ArenaPool.zig Normal file
View File

@@ -0,0 +1,87 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const ArenaPool = @This();
allocator: Allocator,
retain_bytes: usize,
free_list_len: u16 = 0,
free_list: ?*Entry = null,
free_list_max: u16,
entry_pool: std.heap.MemoryPool(Entry),
const Entry = struct {
next: ?*Entry,
arena: ArenaAllocator,
};
pub fn init(allocator: Allocator) ArenaPool {
return .{
.allocator = allocator,
.free_list_max = 512, // TODO make configurable
.retain_bytes = 1024 * 16, // TODO make configurable
.entry_pool = std.heap.MemoryPool(Entry).init(allocator),
};
}
pub fn deinit(self: *ArenaPool) void {
var entry = self.free_list;
while (entry) |e| {
entry = e.next;
e.arena.deinit();
}
self.entry_pool.deinit();
}
pub fn acquire(self: *ArenaPool) !Allocator {
if (self.free_list) |entry| {
self.free_list = entry.next;
self.free_list_len -= 1;
return entry.arena.allocator();
}
const entry = try self.entry_pool.create();
entry.* = .{
.next = null,
.arena = ArenaAllocator.init(self.allocator),
};
return entry.arena.allocator();
}
pub fn release(self: *ArenaPool, allocator: Allocator) void {
const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr));
const entry: *Entry = @fieldParentPtr("arena", arena);
const free_list_len = self.free_list_len;
if (free_list_len == self.free_list_max) {
arena.deinit();
self.entry_pool.destroy(entry);
return;
}
_ = arena.reset(.{ .retain_with_limit = self.retain_bytes });
entry.next = self.free_list;
self.free_list_len = free_list_len + 1;
self.free_list = entry;
}

800
src/Config.zig Normal file
View File

@@ -0,0 +1,800 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const log = @import("log.zig");
const dump = @import("browser/dump.zig");
pub const RunMode = enum {
help,
fetch,
serve,
version,
};
mode: Mode,
exec_name: []const u8,
http_headers: HttpHeaders,
const Config = @This();
pub fn init(allocator: Allocator, exec_name: []const u8, mode: Mode) !Config {
var config = Config{
.mode = mode,
.exec_name = exec_name,
.http_headers = undefined,
};
config.http_headers = try HttpHeaders.init(allocator, &config);
return config;
}
pub fn deinit(self: *const Config, allocator: Allocator) void {
self.http_headers.deinit(allocator);
}
pub fn tlsVerifyHost(self: *const Config) bool {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.tls_verify_host,
else => unreachable,
};
}
pub fn obeyRobots(self: *const Config) bool {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.obey_robots,
else => unreachable,
};
}
pub fn httpProxy(self: *const Config) ?[:0]const u8 {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_proxy,
else => unreachable,
};
}
pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.proxy_bearer_token,
.help, .version => null,
};
}
pub fn httpMaxConcurrent(self: *const Config) u8 {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_max_concurrent orelse 10,
else => unreachable,
};
}
pub fn httpMaxHostOpen(self: *const Config) u8 {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_max_host_open orelse 4,
else => unreachable,
};
}
pub fn httpConnectTimeout(self: *const Config) u31 {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_connect_timeout orelse 0,
else => unreachable,
};
}
pub fn httpTimeout(self: *const Config) u31 {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_timeout orelse 5000,
else => unreachable,
};
}
pub fn httpMaxRedirects(_: *const Config) u8 {
return 10;
}
pub fn httpMaxResponseSize(self: *const Config) ?usize {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_max_response_size,
else => unreachable,
};
}
pub fn logLevel(self: *const Config) ?log.Level {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.log_level,
else => unreachable,
};
}
pub fn logFormat(self: *const Config) ?log.Format {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.log_format,
else => unreachable,
};
}
pub fn logFilterScopes(self: *const Config) ?[]const log.Scope {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.log_filter_scopes,
else => unreachable,
};
}
pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.user_agent_suffix,
.help, .version => null,
};
}
pub const Mode = union(RunMode) {
help: bool, // false when being printed because of an error
fetch: Fetch,
serve: Serve,
version: void,
};
pub const Serve = struct {
host: []const u8 = "127.0.0.1",
port: u16 = 9222,
timeout: u31 = 10,
max_connections: u16 = 16,
max_tabs_per_connection: u16 = 8,
max_memory_per_tab: u64 = 512 * 1024 * 1024,
max_pending_connections: u16 = 128,
common: Common = .{},
};
pub const Fetch = struct {
url: [:0]const u8,
dump: bool = false,
common: Common = .{},
withbase: bool = false,
strip: dump.Opts.Strip = .{},
};
pub const Common = struct {
obey_robots: bool = false,
proxy_bearer_token: ?[:0]const u8 = null,
http_proxy: ?[:0]const u8 = null,
http_max_concurrent: ?u8 = null,
http_max_host_open: ?u8 = null,
http_timeout: ?u31 = null,
http_connect_timeout: ?u31 = null,
http_max_response_size: ?usize = null,
tls_verify_host: bool = true,
log_level: ?log.Level = null,
log_format: ?log.Format = null,
log_filter_scopes: ?[]log.Scope = null,
user_agent_suffix: ?[]const u8 = null,
};
/// Pre-formatted HTTP headers for reuse across Http and Client.
/// Must be initialized with an allocator that outlives all HTTP connections.
pub const HttpHeaders = struct {
const user_agent_base: [:0]const u8 = "Lightpanda/1.0";
user_agent: [:0]const u8, // User agent value (e.g. "Lightpanda/1.0")
user_agent_header: [:0]const u8,
proxy_bearer_header: ?[:0]const u8,
pub fn init(allocator: Allocator, config: *const Config) !HttpHeaders {
const user_agent: [:0]const u8 = if (config.userAgentSuffix()) |suffix|
try std.fmt.allocPrintSentinel(allocator, "{s} {s}", .{ user_agent_base, suffix }, 0)
else
user_agent_base;
errdefer if (config.userAgentSuffix() != null) allocator.free(user_agent);
const user_agent_header = try std.fmt.allocPrintSentinel(allocator, "User-Agent: {s}", .{user_agent}, 0);
errdefer allocator.free(user_agent_header);
const proxy_bearer_header: ?[:0]const u8 = if (config.proxyBearerToken()) |token|
try std.fmt.allocPrintSentinel(allocator, "Proxy-Authorization: Bearer {s}", .{token}, 0)
else
null;
return .{
.user_agent = user_agent,
.user_agent_header = user_agent_header,
.proxy_bearer_header = proxy_bearer_header,
};
}
pub fn deinit(self: *const HttpHeaders, allocator: Allocator) void {
if (self.proxy_bearer_header) |hdr| {
allocator.free(hdr);
}
allocator.free(self.user_agent_header);
if (self.user_agent.ptr != user_agent_base.ptr) {
allocator.free(self.user_agent);
}
}
};
pub fn printUsageAndExit(self: *const Config, success: bool) void {
// MAX_HELP_LEN|
const common_options =
\\
\\--insecure_disable_tls_host_verification
\\ Disables host verification on all HTTP requests. This is an
\\ advanced option which should only be set if you understand
\\ and accept the risk of disabling host verification.
\\
\\--obey_robots
\\ Fetches and obeys the robots.txt (if available) of the web pages
\\ we make requests towards.
\\ Defaults to false.
\\
\\--http_proxy The HTTP proxy to use for all HTTP requests.
\\ A username:password can be included for basic authentication.
\\ Defaults to none.
\\
\\--proxy_bearer_token
\\ The <token> to send for bearer authentication with the proxy
\\ Proxy-Authorization: Bearer <token>
\\
\\--http_max_concurrent
\\ The maximum number of concurrent HTTP requests.
\\ Defaults to 10.
\\
\\--http_max_host_open
\\ The maximum number of open connection to a given host:port.
\\ Defaults to 4.
\\
\\--http_connect_timeout
\\ The time, in milliseconds, for establishing an HTTP connection
\\ before timing out. 0 means it never times out.
\\ Defaults to 0.
\\
\\--http_timeout
\\ The maximum time, in milliseconds, the transfer is allowed
\\ to complete. 0 means it never times out.
\\ Defaults to 10000.
\\
\\--http_max_response_size
\\ Limits the acceptable response size for any request
\\ (e.g. XHR, fetch, script loading, ...).
\\ Defaults to no limit.
\\
\\--log_level The log level: debug, info, warn, error or fatal.
\\ Defaults to
++ (if (builtin.mode == .Debug) " info." else "warn.") ++
\\
\\
\\--log_format The log format: pretty or logfmt.
\\ Defaults to
++ (if (builtin.mode == .Debug) " pretty." else " logfmt.") ++
\\
\\
\\--log_filter_scopes
\\ Filter out too verbose logs per scope:
\\ http, unknown_prop, event, ...
\\
\\--user_agent_suffix
\\ Suffix to append to the Lightpanda/X.Y User-Agent
\\
;
// MAX_HELP_LEN|
const usage =
\\usage: {s} command [options] [URL]
\\
\\Command can be either 'fetch', 'serve' or 'help'
\\
\\fetch command
\\Fetches the specified URL
\\Example: {s} fetch --dump https://lightpanda.io/
\\
\\Options:
\\--dump Dumps document to stdout.
\\ Defaults to false.
\\
\\--strip_mode Comma separated list of tag groups to remove from dump
\\ the dump. e.g. --strip_mode js,css
\\ - "js" script and link[as=script, rel=preload]
\\ - "ui" includes img, picture, video, css and svg
\\ - "css" includes style and link[rel=stylesheet]
\\ - "full" includes js, ui and css
\\
\\--with_base Add a <base> tag in dump. Defaults to false.
\\
++ common_options ++
\\
\\serve command
\\Starts a websocket CDP server
\\Example: {s} serve --host 127.0.0.1 --port 9222
\\
\\Options:
\\--host Host of the CDP server
\\ Defaults to "127.0.0.1"
\\
\\--port Port of the CDP server
\\ Defaults to 9222
\\
\\--timeout Inactivity timeout in seconds before disconnecting clients
\\ Defaults to 10 (seconds). Limited to 604800 (1 week).
\\
\\--max_connections
\\ Maximum number of simultaneous CDP connections.
\\ Defaults to 16.
\\
\\--max_tabs Maximum number of tabs per CDP connection.
\\ Defaults to 8.
\\
\\--max_tab_memory
\\ Maximum memory per tab in bytes.
\\ Defaults to 536870912 (512 MB).
\\
\\--max_pending_connections
\\ Maximum pending connections in the accept queue.
\\ Defaults to 128.
\\
++ common_options ++
\\
\\version command
\\Displays the version of {s}
\\
\\help command
\\Displays this message
\\
;
std.debug.print(usage, .{ self.exec_name, self.exec_name, self.exec_name, self.exec_name });
if (success) {
return std.process.cleanExit();
}
std.process.exit(1);
}
pub fn parseArgs(allocator: Allocator) !Config {
var args = try std.process.argsWithAllocator(allocator);
defer args.deinit();
const exec_name = try allocator.dupe(u8, std.fs.path.basename(args.next().?));
const mode_string = args.next() orelse "";
const run_mode = std.meta.stringToEnum(RunMode, mode_string) orelse blk: {
const inferred_mode = inferMode(mode_string) orelse
return init(allocator, exec_name, .{ .help = false });
// "command" wasn't a command but an option. We can't reset args, but
// we can create a new one. Not great, but this fallback is temporary
// as we transition to this command mode approach.
args.deinit();
args = try std.process.argsWithAllocator(allocator);
// skip the exec_name
_ = args.skip();
break :blk inferred_mode;
};
const mode: Mode = switch (run_mode) {
.help => .{ .help = true },
.serve => .{ .serve = parseServeArgs(allocator, &args) catch
return init(allocator, exec_name, .{ .help = false }) },
.fetch => .{ .fetch = parseFetchArgs(allocator, &args) catch
return init(allocator, exec_name, .{ .help = false }) },
.version => .{ .version = {} },
};
return init(allocator, exec_name, mode);
}
fn inferMode(opt: []const u8) ?RunMode {
if (opt.len == 0) {
return .serve;
}
if (std.mem.startsWith(u8, opt, "--") == false) {
return .fetch;
}
if (std.mem.eql(u8, opt, "--dump")) {
return .fetch;
}
if (std.mem.eql(u8, opt, "--noscript")) {
return .fetch;
}
if (std.mem.eql(u8, opt, "--strip_mode")) {
return .fetch;
}
if (std.mem.eql(u8, opt, "--with_base")) {
return .fetch;
}
if (std.mem.eql(u8, opt, "--host")) {
return .serve;
}
if (std.mem.eql(u8, opt, "--port")) {
return .serve;
}
if (std.mem.eql(u8, opt, "--timeout")) {
return .serve;
}
return null;
}
fn parseServeArgs(
allocator: Allocator,
args: *std.process.ArgIterator,
) !Serve {
var serve: Serve = .{};
while (args.next()) |opt| {
if (std.mem.eql(u8, "--host", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--host" });
return error.InvalidArgument;
};
serve.host = try allocator.dupe(u8, str);
continue;
}
if (std.mem.eql(u8, "--port", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--port" });
return error.InvalidArgument;
};
serve.port = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--port", .err = err });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--timeout", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--timeout" });
return error.InvalidArgument;
};
serve.timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--timeout", .err = err });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--max_connections", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--max_connections" });
return error.InvalidArgument;
};
serve.max_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--max_connections", .err = err });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--max_tabs", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--max_tabs" });
return error.InvalidArgument;
};
serve.max_tabs_per_connection = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--max_tabs", .err = err });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--max_tab_memory", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--max_tab_memory" });
return error.InvalidArgument;
};
serve.max_memory_per_tab = std.fmt.parseInt(u64, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--max_tab_memory", .err = err });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--max_pending_connections", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--max_pending_connections" });
return error.InvalidArgument;
};
serve.max_pending_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--max_pending_connections", .err = err });
return error.InvalidArgument;
};
continue;
}
if (try parseCommonArg(allocator, opt, args, &serve.common)) {
continue;
}
log.fatal(.app, "unknown argument", .{ .mode = "serve", .arg = opt });
return error.UnkownOption;
}
return serve;
}
fn parseFetchArgs(
allocator: Allocator,
args: *std.process.ArgIterator,
) !Fetch {
var fetch_dump: bool = false;
var withbase: bool = false;
var url: ?[:0]const u8 = null;
var common: Common = .{};
var strip: dump.Opts.Strip = .{};
while (args.next()) |opt| {
if (std.mem.eql(u8, "--dump", opt)) {
fetch_dump = true;
continue;
}
if (std.mem.eql(u8, "--noscript", opt)) {
log.warn(.app, "deprecation warning", .{
.feature = "--noscript argument",
.hint = "use '--strip_mode js' instead",
});
strip.js = true;
continue;
}
if (std.mem.eql(u8, "--with_base", opt)) {
withbase = true;
continue;
}
if (std.mem.eql(u8, "--strip_mode", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--strip_mode" });
return error.InvalidArgument;
};
var it = std.mem.splitScalar(u8, str, ',');
while (it.next()) |part| {
const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace);
if (std.mem.eql(u8, trimmed, "js")) {
strip.js = true;
} else if (std.mem.eql(u8, trimmed, "ui")) {
strip.ui = true;
} else if (std.mem.eql(u8, trimmed, "css")) {
strip.css = true;
} else if (std.mem.eql(u8, trimmed, "full")) {
strip.js = true;
strip.ui = true;
strip.css = true;
} else {
log.fatal(.app, "invalid option choice", .{ .arg = "--strip_mode", .value = trimmed });
}
}
continue;
}
if (try parseCommonArg(allocator, opt, args, &common)) {
continue;
}
if (std.mem.startsWith(u8, opt, "--")) {
log.fatal(.app, "unknown argument", .{ .mode = "fetch", .arg = opt });
return error.UnkownOption;
}
if (url != null) {
log.fatal(.app, "duplicate fetch url", .{ .help = "only 1 URL can be specified" });
return error.TooManyURLs;
}
url = try allocator.dupeZ(u8, opt);
}
if (url == null) {
log.fatal(.app, "missing fetch url", .{ .help = "URL to fetch must be provided" });
return error.MissingURL;
}
return .{
.url = url.?,
.dump = fetch_dump,
.strip = strip,
.common = common,
.withbase = withbase,
};
}
fn parseCommonArg(
allocator: Allocator,
opt: []const u8,
args: *std.process.ArgIterator,
common: *Common,
) !bool {
if (std.mem.eql(u8, "--insecure_disable_tls_host_verification", opt)) {
common.tls_verify_host = false;
return true;
}
if (std.mem.eql(u8, "--obey_robots", opt)) {
common.obey_robots = true;
return true;
}
if (std.mem.eql(u8, "--http_proxy", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_proxy" });
return error.InvalidArgument;
};
common.http_proxy = try allocator.dupeZ(u8, str);
return true;
}
if (std.mem.eql(u8, "--proxy_bearer_token", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--proxy_bearer_token" });
return error.InvalidArgument;
};
common.proxy_bearer_token = try allocator.dupeZ(u8, str);
return true;
}
if (std.mem.eql(u8, "--http_max_concurrent", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_concurrent" });
return error.InvalidArgument;
};
common.http_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_concurrent", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--http_max_host_open", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_host_open" });
return error.InvalidArgument;
};
common.http_max_host_open = std.fmt.parseInt(u8, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_host_open", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--http_connect_timeout", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_connect_timeout" });
return error.InvalidArgument;
};
common.http_connect_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_connect_timeout", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--http_timeout", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_timeout" });
return error.InvalidArgument;
};
common.http_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_timeout", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--http_max_response_size", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_response_size" });
return error.InvalidArgument;
};
common.http_max_response_size = std.fmt.parseInt(usize, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_response_size", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--log_level", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--log_level" });
return error.InvalidArgument;
};
common.log_level = std.meta.stringToEnum(log.Level, str) orelse blk: {
if (std.mem.eql(u8, str, "error")) {
break :blk .err;
}
log.fatal(.app, "invalid option choice", .{ .arg = "--log_level", .value = str });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--log_format", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--log_format" });
return error.InvalidArgument;
};
common.log_format = std.meta.stringToEnum(log.Format, str) orelse {
log.fatal(.app, "invalid option choice", .{ .arg = "--log_format", .value = str });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--log_filter_scopes", opt)) {
if (builtin.mode != .Debug) {
log.fatal(.app, "experimental", .{ .help = "log scope filtering is only available in debug builds" });
return false;
}
const str = args.next() orelse {
// disables the default filters
common.log_filter_scopes = &.{};
return true;
};
var arr: std.ArrayList(log.Scope) = .empty;
var it = std.mem.splitScalar(u8, str, ',');
while (it.next()) |part| {
try arr.append(allocator, std.meta.stringToEnum(log.Scope, part) orelse {
log.fatal(.app, "invalid option choice", .{ .arg = "--log_filter_scopes", .value = part });
return false;
});
}
common.log_filter_scopes = arr.items;
return true;
}
if (std.mem.eql(u8, "--user_agent_suffix", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--user_agent_suffix" });
return error.InvalidArgument;
};
for (str) |c| {
if (!std.ascii.isPrint(c)) {
log.fatal(.app, "not printable character", .{ .arg = "--user_agent_suffix" });
return error.InvalidArgument;
}
}
common.user_agent_suffix = try allocator.dupe(u8, str);
return true;
}
return false;
}

View File

@@ -39,10 +39,9 @@ const List = std.DoublyLinkedList;
// CDP code registers for the "network_bytes_sent" event, because it needs to
// send messages to the client when this happens. Our HTTP client could then
// emit a "network_bytes_sent" message. It would be easy, and it would work.
// That is, it would work until the Telemetry code makes an HTTP request, and
// because everything's just one big global, that gets picked up by the
// registered CDP listener, and the telemetry network activity gets sent to the
// CDP client.
// That is, it would work until multiple CDP clients connect, and because
// everything's just one big global, events from one CDP session would be sent
// to all CDP clients.
//
// To avoid this, one way or another, we need scoping. We could still have
// a global registry but every "register" and every "emit" has some type of
@@ -50,14 +49,10 @@ const List = std.DoublyLinkedList;
// between components to share a common scope.
//
// Instead, the approach that we take is to have a notification instance per
// scope. This makes some things harder, but we only plan on having 2
// notification instances at a given time: one in a Browser and one in the App.
// What about something like Telemetry, which lives outside of a Browser but
// still cares about Browser-events (like .page_navigate)? When the Browser
// notification is created, a `notification_created` event is raised in the
// App's notification, which Telemetry is registered for. This allows Telemetry
// to register for events in the Browser notification. See the Telemetry's
// register function.
// CDP connection (BrowserContext). Each CDP connection has its own notification
// that is shared across all Sessions (tabs) within that connection. This ensures
// proper isolation between different CDP clients while allowing a single client
// to receive events from all its tabs.
const Notification = @This();
// Every event type (which are hard-coded), has a list of Listeners.
// When the event happens, we dispatch to those listener.
@@ -66,7 +61,7 @@ event_listeners: EventListeners,
// list of listeners for a specified receiver
// @intFromPtr(receiver) -> [listener1, listener2, ...]
// Used when `unregisterAll` is called.
listeners: std.AutoHashMapUnmanaged(usize, std.ArrayListUnmanaged(*Listener)),
listeners: std.AutoHashMapUnmanaged(usize, std.ArrayList(*Listener)),
allocator: Allocator,
mem_pool: std.heap.MemoryPool(Listener),
@@ -85,7 +80,6 @@ const EventListeners = struct {
http_request_auth_required: List = .{},
http_response_data: List = .{},
http_response_header_done: List = .{},
notification_created: List = .{},
};
const Events = union(enum) {
@@ -102,7 +96,6 @@ const Events = union(enum) {
http_request_done: *const RequestDone,
http_response_data: *const ResponseData,
http_response_header_done: *const ResponseHeaderDone,
notification_created: *Notification,
};
const EventType = std.meta.FieldEnum(Events);
@@ -162,12 +155,7 @@ pub const RequestFail = struct {
err: anyerror,
};
pub fn init(allocator: Allocator, parent: ?*Notification) !*Notification {
// This is put on the heap because we want to raise a .notification_created
// event, so that, something like Telemetry, can receive the
// .page_navigate event on all notification instances. That can only work
// if we dispatch .notification_created with a *Notification.
pub fn init(allocator: Allocator) !*Notification {
const notification = try allocator.create(Notification);
errdefer allocator.destroy(notification);
@@ -178,10 +166,6 @@ pub fn init(allocator: Allocator, parent: ?*Notification) !*Notification {
.mem_pool = std.heap.MemoryPool(Listener).init(allocator),
};
if (parent) |pn| {
pn.dispatch(.notification_created, notification);
}
return notification;
}
@@ -256,6 +240,9 @@ pub fn unregisterAll(self: *Notification, receiver: *anyopaque) void {
}
pub fn dispatch(self: *Notification, comptime event: EventType, data: ArgType(event)) void {
if (self.listeners.count() == 0) {
return;
}
const list = &@field(self.event_listeners, @tagName(event));
var node = list.first;
@@ -313,7 +300,7 @@ const Listener = struct {
const testing = std.testing;
test "Notification" {
var notifier = try Notification.init(testing.allocator, null);
var notifier = try Notification.init(testing.allocator);
defer notifier.deinit();
// noop

View File

@@ -205,7 +205,6 @@ fn readLoop(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void {
}
ms_remaining -= @intCast(elapsed);
},
.navigate => unreachable, // must have been handled by the session
}
}
}
@@ -561,7 +560,7 @@ pub const Client = struct {
pub fn sendJSONRaw(
self: *Client,
buf: std.ArrayListUnmanaged(u8),
buf: std.ArrayList(u8),
) !void {
// Dangerous API!. We assume the caller has reserved the first 10
// bytes in `buf`.
@@ -883,7 +882,7 @@ fn growBuffer(allocator: Allocator, buf: []u8, required_capacity: usize) ![]u8 {
const Fragments = struct {
type: Message.Type,
message: std.ArrayListUnmanaged(u8),
message: std.ArrayList(u8),
};
const Message = struct {
@@ -907,7 +906,7 @@ const OpCode = enum(u8) {
pong = 128 | 10,
};
fn fillWebsocketHeader(buf: std.ArrayListUnmanaged(u8)) []const u8 {
fn fillWebsocketHeader(buf: std.ArrayList(u8)) []const u8 {
// can't use buf[0..10] here, because the header length
// is variable. If it's just 2 bytes, for example, we need the
// framed message to be:
@@ -1342,7 +1341,7 @@ fn assertWebSocketMessage(expected: []const u8, input: []const u8) !void {
}
const MockCDP = struct {
messages: std.ArrayListUnmanaged([]const u8) = .{},
messages: std.ArrayList([]const u8) = .{},
allocator: Allocator = testing.allocator,

View File

@@ -20,7 +20,7 @@ const std = @import("std");
const TestHTTPServer = @This();
shutdown: bool,
shutdown: std.atomic.Value(bool),
listener: ?std.net.Server,
handler: Handler,
@@ -28,16 +28,23 @@ const Handler = *const fn (req: *std.http.Server.Request) anyerror!void;
pub fn init(handler: Handler) TestHTTPServer {
return .{
.shutdown = true,
.shutdown = .init(true),
.listener = null,
.handler = handler,
};
}
pub fn deinit(self: *TestHTTPServer) void {
self.shutdown = true;
self.listener = null;
}
pub fn stop(self: *TestHTTPServer) void {
self.shutdown.store(true, .release);
if (self.listener) |*listener| {
listener.deinit();
switch (@import("builtin").target.os.tag) {
.linux => std.posix.shutdown(listener.stream.handle, .recv) catch {},
else => std.posix.close(listener.stream.handle),
}
}
}
@@ -46,12 +53,13 @@ pub fn run(self: *TestHTTPServer, wg: *std.Thread.WaitGroup) !void {
self.listener = try address.listen(.{ .reuse_address = true });
var listener = &self.listener.?;
self.shutdown.store(false, .release);
wg.finish();
while (true) {
const conn = listener.accept() catch |err| {
if (self.shutdown) {
if (self.shutdown.load(.acquire) or err == error.SocketNotListening) {
return;
}
return err;

View File

@@ -24,12 +24,14 @@ const ArenaAllocator = std.heap.ArenaAllocator;
const js = @import("js/js.zig");
const log = @import("../log.zig");
const App = @import("../App.zig");
const HttpClient = @import("../http/Client.zig");
const Notification = @import("../Notification.zig");
const ArenaPool = App.ArenaPool;
const HttpClient = App.Http.Client;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Session = @import("Session.zig");
const Notification = @import("../Notification.zig");
// Browser is an instance of the browser.
// You can create multiple browser instances.
@@ -40,30 +42,29 @@ env: js.Env,
app: *App,
session: ?Session,
allocator: Allocator,
arena_pool: *ArenaPool,
http_client: *HttpClient,
call_arena: ArenaAllocator,
page_arena: ArenaAllocator,
session_arena: ArenaAllocator,
transfer_arena: ArenaAllocator,
notification: *Notification,
pub fn init(app: *App) !Browser {
const InitOpts = struct {
env: js.Env.InitOpts = .{},
};
pub fn init(app: *App, opts: InitOpts) !Browser {
const allocator = app.allocator;
var env = try js.Env.init(allocator, &app.platform, &app.snapshot);
var env = try js.Env.init(app, opts.env);
errdefer env.deinit();
const notification = try Notification.init(allocator, app.notification);
app.http.client.notification = notification;
app.http.client.next_request_id = 0; // Should we track ids in CDP only?
errdefer notification.deinit();
return .{
.app = app,
.env = env,
.session = null,
.allocator = allocator,
.notification = notification,
.arena_pool = &app.arena_pool,
.http_client = app.http.client,
.call_arena = ArenaAllocator.init(allocator),
.page_arena = ArenaAllocator.init(allocator),
@@ -79,15 +80,13 @@ pub fn deinit(self: *Browser) void {
self.page_arena.deinit();
self.session_arena.deinit();
self.transfer_arena.deinit();
self.http_client.notification = null;
self.notification.deinit();
}
pub fn newSession(self: *Browser) !*Session {
pub fn newSession(self: *Browser, notification: *Notification) !*Session {
self.closeSession();
self.session = @as(Session, undefined);
const session = &self.session.?;
try Session.init(session, self);
try Session.init(session, self, notification);
return session;
}
@@ -96,7 +95,7 @@ pub fn closeSession(self: *Browser) void {
session.deinit();
self.session = null;
_ = self.session_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
self.env.lowMemoryNotification();
self.env.memoryPressureNotification(.critical);
}
}
@@ -104,6 +103,10 @@ pub fn runMicrotasks(self: *const Browser) void {
self.env.runMicrotasks();
}
pub fn runMacrotasks(self: *Browser) !?u64 {
return try self.env.runMacrotasks();
}
pub fn runMessageLoop(self: *const Browser) void {
while (self.env.pumpMessageLoop()) {
if (comptime IS_DEBUG) {

View File

@@ -33,13 +33,36 @@ const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
const EventKey = struct {
event_target: usize,
type_string: String,
};
const EventKeyContext = struct {
pub fn hash(_: @This(), key: EventKey) u64 {
var hasher = std.hash.Wyhash.init(0);
hasher.update(std.mem.asBytes(&key.event_target));
hasher.update(key.type_string.str());
return hasher.final();
}
pub fn eql(_: @This(), a: EventKey, b: EventKey) bool {
return a.event_target == b.event_target and a.type_string.eql(b.type_string);
}
};
pub const EventManager = @This();
page: *Page,
arena: Allocator,
listener_pool: std.heap.MemoryPool(Listener),
list_pool: std.heap.MemoryPool(std.DoublyLinkedList),
lookup: std.AutoHashMapUnmanaged(usize, *std.DoublyLinkedList),
lookup: std.HashMapUnmanaged(
EventKey,
*std.DoublyLinkedList,
EventKeyContext,
std.hash_map.default_max_load_percentage,
),
dispatch_depth: usize,
deferred_removals: std.ArrayList(struct { list: *std.DoublyLinkedList, listener: *Listener }),
@@ -69,7 +92,7 @@ pub const Callback = union(enum) {
pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, opts: RegisterOptions) !void {
if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.register", .{ .type = typ, .capture = opts.capture, .once = opts.once, .target = target });
log.debug(.event, "eventManager.register", .{ .type = typ, .capture = opts.capture, .once = opts.once, .target = target.toString() });
}
// If a signal is provided and already aborted, don't register the listener
@@ -79,20 +102,24 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call
}
}
const gop = try self.lookup.getOrPut(self.arena, @intFromPtr(target));
// Allocate the type string we'll use in both listener and key
const type_string = try String.init(self.arena, typ, .{});
const gop = try self.lookup.getOrPut(self.arena, .{
.type_string = type_string,
.event_target = @intFromPtr(target),
});
if (gop.found_existing) {
// check for duplicate callbacks already registered
var node = gop.value_ptr.*.first;
while (node) |n| {
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
if (listener.typ.eqlSlice(typ)) {
const is_duplicate = switch (callback) {
.object => |obj| listener.function.eqlObject(obj),
.function => |func| listener.function.eqlFunction(func),
};
if (is_duplicate and listener.capture == opts.capture) {
return;
}
const is_duplicate = switch (callback) {
.object => |obj| listener.function.eqlObject(obj),
.function => |func| listener.function.eqlFunction(func),
};
if (is_duplicate and listener.capture == opts.capture) {
return;
}
node = n.next;
}
@@ -114,20 +141,34 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call
.passive = opts.passive,
.function = func,
.signal = opts.signal,
.typ = try String.init(self.arena, typ, .{}),
.typ = type_string,
};
// append the listener to the list of listeners for this target
gop.value_ptr.*.append(&listener.node);
}
pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void {
const list = self.lookup.get(@intFromPtr(target)) orelse return;
if (findListener(list, typ, callback, use_capture)) |listener| {
const list = self.lookup.get(.{
.type_string = .wrap(typ),
.event_target = @intFromPtr(target),
}) orelse return;
if (findListener(list, callback, use_capture)) |listener| {
self.removeListener(list, listener);
}
}
pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void {
// Dispatching can be recursive from the compiler's point of view, so we need to
// give it an explicit error set so that other parts of the code can use and
// inferred error.
const DispatchError = error{
OutOfMemory,
StringTooLarge,
JSExecCallback,
CompilationError,
ExecutionError,
JsException,
};
pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) DispatchError!void {
if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
}
@@ -137,7 +178,10 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void
var was_handled = false;
defer if (was_handled) {
self.page.js.runMicrotasks();
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
};
switch (target._type) {
@@ -151,9 +195,13 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void
.navigation,
.screen,
.screen_orientation,
.visual_viewport,
.generic,
=> {
const list = self.lookup.get(@intFromPtr(target)) orelse return;
const list = self.lookup.get(.{
.event_target = @intFromPtr(target),
.type_string = event._type_string,
}) orelse return;
try self.dispatchAll(list, target, event, &was_handled);
},
}
@@ -180,7 +228,10 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
var was_dispatched = false;
defer if (was_dispatched) {
self.page.js.runMicrotasks();
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
};
if (function_) |func| {
@@ -193,7 +244,10 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
}
}
const list = self.lookup.get(@intFromPtr(target)) orelse return;
const list = self.lookup.get(.{
.event_target = @intFromPtr(target),
.type_string = event._type_string,
}) orelse return;
try self.dispatchAll(list, target, event, &was_dispatched);
}
@@ -261,7 +315,10 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
while (i > 1) {
i -= 1;
const current_target = path[i];
if (self.lookup.get(@intFromPtr(current_target))) |list| {
if (self.lookup.get(.{
.event_target = @intFromPtr(current_target),
.type_string = event._type_string,
})) |list| {
try self.dispatchPhase(list, current_target, event, was_handled, true);
if (event._stop_propagation) {
return;
@@ -272,7 +329,10 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
// Phase 2: At target
event._event_phase = .at_target;
const target_et = target.asEventTarget();
if (self.lookup.get(@intFromPtr(target_et))) |list| {
if (self.lookup.get(.{
.type_string = event._type_string,
.event_target = @intFromPtr(target_et),
})) |list| {
try self.dispatchPhase(list, target_et, event, was_handled, null);
if (event._stop_propagation) {
return;
@@ -284,7 +344,10 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
if (event._bubbles) {
event._event_phase = .bubbling_phase;
for (path[1..]) |current_target| {
if (self.lookup.get(@intFromPtr(current_target))) |list| {
if (self.lookup.get(.{
.type_string = event._type_string,
.event_target = @intFromPtr(current_target),
})) |list| {
try self.dispatchPhase(list, current_target, event, was_handled, false);
if (event._stop_propagation) {
break;
@@ -296,7 +359,6 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime capture_only: ?bool) !void {
const page = self.page;
const typ = event._type_string;
// Track dispatch depth for deferred removal
self.dispatch_depth += 1;
@@ -331,9 +393,6 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
node = n.next;
// Skip non-matching listeners
if (!listener.typ.eql(typ)) {
continue;
}
if (comptime capture_only) |capture| {
if (listener.capture != capture) {
continue;
@@ -367,14 +426,18 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
event._target = getAdjustedTarget(original_target, current_target);
}
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
switch (listener.function) {
.value => |value| try value.local().callWithThis(void, current_target, .{event}),
.value => |value| try ls.toLocal(value).callWithThis(void, current_target, .{event}),
.string => |string| {
const str = try page.call_arena.dupeZ(u8, string.str());
try self.page.js.eval(str, null);
try ls.local.eval(str, null);
},
.object => |*obj_global| {
const obj = obj_global.local();
.object => |obj_global| {
const obj = ls.toLocal(obj_global);
if (try obj.getFunction("handleEvent")) |handleEvent| {
try handleEvent.callWithThis(void, obj, .{event});
}
@@ -409,7 +472,7 @@ fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *L
}
}
fn findListener(list: *const std.DoublyLinkedList, typ: []const u8, callback: Callback, capture: bool) ?*Listener {
fn findListener(list: *const std.DoublyLinkedList, callback: Callback, capture: bool) ?*Listener {
var node = list.first;
while (node) |n| {
node = n.next;
@@ -424,9 +487,6 @@ fn findListener(list: *const std.DoublyLinkedList, typ: []const u8, callback: Ca
if (listener.capture != capture) {
continue;
}
if (!listener.typ.eqlSlice(typ)) {
continue;
}
return listener;
}
return null;

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -29,6 +29,7 @@ const Page = @import("Page.zig");
const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig");
const UIEvent = @import("webapi/event/UIEvent.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig");
const Element = @import("webapi/Element.zig");
const Document = @import("webapi/Document.zig");
const EventTarget = @import("webapi/EventTarget.zig");
@@ -36,6 +37,8 @@ const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.
const Blob = @import("webapi/Blob.zig");
const AbstractRange = @import("webapi/AbstractRange.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
const assert = std.debug.assert;
@@ -169,50 +172,69 @@ pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
return chain.get(1);
}
fn eventInit(typ: []const u8, value: anytype, page: *Page) !Event {
// Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2;
return .{
._type = unionInit(Event.Type, value),
._type_string = try String.init(page.arena, typ, .{}),
._time_stamp = time_stamp,
};
}
// this is a root object
pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
pub fn event(self: *Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, @TypeOf(child) },
).allocate(allocator);
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(typ, chain.get(1), self._page);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn uiEvent(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
pub fn uiEvent(self: *Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, @TypeOf(child) },
).allocate(allocator);
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(typ, chain.get(1), self._page);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
chain.setLeaf(2, child);
return chain.get(2);
}
pub fn mouseEvent(self: *Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, MouseEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
// Set MouseEvent with all its fields
const mouse_ptr = chain.get(2);
mouse_ptr.* = mouse;
mouse_ptr._proto = chain.get(1);
mouse_ptr._type = unionInit(MouseEvent.Type, chain.get(3));
chain.setLeaf(3, child);
return chain.get(3);
}
fn eventInit(self: *const Factory, arena: Allocator, typ: String, value: anytype) !Event {
// Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2;
return .{
._arena = arena,
._page = self._page,
._type = unionInit(Event.Type, value),
._type_string = typ,
._time_stamp = time_stamp,
};
}
pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
@@ -320,9 +342,7 @@ pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeO
return chain.get(4);
}
pub fn xhrEventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
pub fn xhrEventTarget(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) {
return try AutoPrototypeChain(
&.{ EventTarget, XMLHttpRequestEventTarget, @TypeOf(child) },
).create(allocator, child);
@@ -337,32 +357,6 @@ pub fn textTrackCue(self: *Factory, child: anytype) !*@TypeOf(child) {
).create(allocator, child);
}
fn hasChainRoot(comptime T: type) bool {
// Check if this is a root
if (@hasDecl(T, "_prototype_root")) {
return true;
}
// If no _proto field, we're at the top but not a recognized root
if (!@hasField(T, "_proto")) return false;
// Get the _proto field's type and recurse
const fields = @typeInfo(T).@"struct".fields;
inline for (fields) |field| {
if (std.mem.eql(u8, field.name, "_proto")) {
const ProtoType = reflect.Struct(field.type);
return hasChainRoot(ProtoType);
}
}
return false;
}
fn isChainType(comptime T: type) bool {
if (@hasField(T, "_proto")) return false;
return comptime hasChainRoot(T);
}
pub fn destroy(self: *Factory, value: anytype) void {
const S = reflect.Struct(@TypeOf(value));
@@ -379,7 +373,7 @@ pub fn destroy(self: *Factory, value: anytype) void {
}
}
if (comptime isChainType(S)) {
if (comptime @hasField(S, "_proto")) {
self.destroyChain(value, true, 0, std.mem.Alignment.@"1");
} else {
self.destroyStandalone(value);
@@ -387,20 +381,7 @@ pub fn destroy(self: *Factory, value: anytype) void {
}
pub fn destroyStandalone(self: *Factory, value: anytype) void {
const S = reflect.Struct(@TypeOf(value));
assert(!@hasDecl(S, "_prototype_root"));
const allocator = self._slab.allocator();
if (@hasDecl(S, "deinit")) {
// And it has a deinit, we'll call it
switch (@typeInfo(@TypeOf(S.deinit)).@"fn".params.len) {
1 => value.deinit(),
2 => value.deinit(self._page),
else => @compileLog(@typeName(S) ++ " has an invalid deinit function"),
}
}
allocator.destroy(value);
}
@@ -416,10 +397,8 @@ fn destroyChain(
// aligns the old size to the alignment of this element
const current_size = std.mem.alignForward(usize, old_size, @alignOf(S));
const alignment = std.mem.Alignment.fromByteUnits(@alignOf(S));
const new_align = std.mem.Alignment.max(old_align, alignment);
const new_size = current_size + @sizeOf(S);
const new_align = std.mem.Alignment.max(old_align, std.mem.Alignment.of(S));
// This is initially called from a deinit. We don't want to call that
// same deinit. So when this is the first time destroyChain is called
@@ -438,20 +417,15 @@ fn destroyChain(
if (@hasField(S, "_proto")) {
self.destroyChain(value._proto, false, new_size, new_align);
} else if (@hasDecl(S, "JsApi")) {
// Doesn't have a _proto, but has a JsApi.
if (self._page.js.removeTaggedMapping(@intFromPtr(value))) |tagged| {
allocator.destroy(tagged);
}
} else {
// no proto so this is the head of the chain.
// we use this as the ptr to the start of the chain.
// and we have summed up the length.
assert(@hasDecl(S, "_prototype_root"));
const memory_ptr: [*]const u8 = @ptrCast(value);
const memory_ptr: [*]u8 = @ptrCast(@constCast(value));
const len = std.mem.alignForward(usize, new_size, new_align.toByteUnits());
allocator.free(memory_ptr[0..len]);
allocator.rawFree(memory_ptr[0..len], new_align, @returnAddress());
}
}

File diff suppressed because it is too large Load Diff

884
src/browser/Robots.zig Normal file
View File

@@ -0,0 +1,884 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../log.zig");
pub const Rule = union(enum) {
allow: []const u8,
disallow: []const u8,
};
pub const Key = enum {
@"user-agent",
allow,
disallow,
};
/// https://www.rfc-editor.org/rfc/rfc9309.html
pub const Robots = @This();
pub const empty: Robots = .{ .rules = &.{} };
pub const RobotStore = struct {
const RobotsEntry = union(enum) {
present: Robots,
absent,
};
pub const RobotsMap = std.HashMapUnmanaged([]const u8, RobotsEntry, struct {
const Context = @This();
pub fn hash(_: Context, value: []const u8) u32 {
var hasher = std.hash.Wyhash.init(value.len);
for (value) |c| {
std.hash.autoHash(&hasher, std.ascii.toLower(c));
}
return @truncate(hasher.final());
}
pub fn eql(_: Context, a: []const u8, b: []const u8) bool {
return std.ascii.eqlIgnoreCase(a, b);
}
}, 80);
allocator: std.mem.Allocator,
map: RobotsMap,
pub fn init(allocator: std.mem.Allocator) RobotStore {
return .{ .allocator = allocator, .map = .empty };
}
pub fn deinit(self: *RobotStore) void {
var iter = self.map.iterator();
while (iter.next()) |entry| {
self.allocator.free(entry.key_ptr.*);
switch (entry.value_ptr.*) {
.present => |*robots| robots.deinit(self.allocator),
.absent => {},
}
}
self.map.deinit(self.allocator);
}
pub fn get(self: *RobotStore, url: []const u8) ?RobotsEntry {
return self.map.get(url);
}
pub fn robotsFromBytes(self: *RobotStore, user_agent: []const u8, bytes: []const u8) !Robots {
return try Robots.fromBytes(self.allocator, user_agent, bytes);
}
pub fn put(self: *RobotStore, url: []const u8, robots: Robots) !void {
const duped = try self.allocator.dupe(u8, url);
try self.map.put(self.allocator, duped, .{ .present = robots });
}
pub fn putAbsent(self: *RobotStore, url: []const u8) !void {
const duped = try self.allocator.dupe(u8, url);
try self.map.put(self.allocator, duped, .absent);
}
};
rules: []const Rule,
const State = struct {
entry: enum {
not_in_entry,
in_other_entry,
in_our_entry,
in_wildcard_entry,
},
has_rules: bool = false,
};
fn freeRulesInList(allocator: std.mem.Allocator, rules: []const Rule) void {
for (rules) |rule| {
switch (rule) {
.allow => |value| allocator.free(value),
.disallow => |value| allocator.free(value),
}
}
}
fn parseRulesWithUserAgent(
allocator: std.mem.Allocator,
user_agent: []const u8,
raw_bytes: []const u8,
) ![]const Rule {
var rules: std.ArrayList(Rule) = .empty;
defer rules.deinit(allocator);
var wildcard_rules: std.ArrayList(Rule) = .empty;
defer wildcard_rules.deinit(allocator);
var state: State = .{ .entry = .not_in_entry, .has_rules = false };
// https://en.wikipedia.org/wiki/Byte_order_mark
const UTF8_BOM: []const u8 = &.{ 0xEF, 0xBB, 0xBF };
// Strip UTF8 BOM
const bytes = if (std.mem.startsWith(u8, raw_bytes, UTF8_BOM))
raw_bytes[3..]
else
raw_bytes;
var iter = std.mem.splitScalar(u8, bytes, '\n');
while (iter.next()) |line| {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
// Skip all comment lines.
if (std.mem.startsWith(u8, trimmed, "#")) continue;
// Remove end of line comment.
const true_line = if (std.mem.indexOfScalar(u8, trimmed, '#')) |pos|
std.mem.trimRight(u8, trimmed[0..pos], &std.ascii.whitespace)
else
trimmed;
if (true_line.len == 0) continue;
const colon_idx = std.mem.indexOfScalar(u8, true_line, ':') orelse {
log.warn(.browser, "robots line missing colon", .{ .line = line });
continue;
};
const key_str = try std.ascii.allocLowerString(allocator, true_line[0..colon_idx]);
defer allocator.free(key_str);
const key = std.meta.stringToEnum(Key, key_str) orelse continue;
const value = std.mem.trim(u8, true_line[colon_idx + 1 ..], &std.ascii.whitespace);
switch (key) {
.@"user-agent" => {
if (state.has_rules) {
state = .{ .entry = .not_in_entry, .has_rules = false };
}
switch (state.entry) {
.in_other_entry => {
if (std.ascii.eqlIgnoreCase(user_agent, value)) {
state.entry = .in_our_entry;
}
},
.in_our_entry => {},
.in_wildcard_entry => {
if (std.ascii.eqlIgnoreCase(user_agent, value)) {
state.entry = .in_our_entry;
}
},
.not_in_entry => {
if (std.ascii.eqlIgnoreCase(user_agent, value)) {
state.entry = .in_our_entry;
} else if (std.mem.eql(u8, "*", value)) {
state.entry = .in_wildcard_entry;
} else {
state.entry = .in_other_entry;
}
},
}
},
.allow => {
defer state.has_rules = true;
switch (state.entry) {
.in_our_entry => {
const duped_value = try allocator.dupe(u8, value);
errdefer allocator.free(duped_value);
try rules.append(allocator, .{ .allow = duped_value });
},
.in_other_entry => {},
.in_wildcard_entry => {
const duped_value = try allocator.dupe(u8, value);
errdefer allocator.free(duped_value);
try wildcard_rules.append(allocator, .{ .allow = duped_value });
},
.not_in_entry => {
log.warn(.browser, "robots unexpected rule", .{ .rule = "allow" });
continue;
},
}
},
.disallow => {
defer state.has_rules = true;
switch (state.entry) {
.in_our_entry => {
const duped_value = try allocator.dupe(u8, value);
errdefer allocator.free(duped_value);
try rules.append(allocator, .{ .disallow = duped_value });
},
.in_other_entry => {},
.in_wildcard_entry => {
const duped_value = try allocator.dupe(u8, value);
errdefer allocator.free(duped_value);
try wildcard_rules.append(allocator, .{ .disallow = duped_value });
},
.not_in_entry => {
log.warn(.browser, "robots unexpected rule", .{ .rule = "disallow" });
continue;
},
}
},
}
}
// If we have rules for our specific User-Agent, we will use those rules.
// If we don't have any rules, we fallback to using the wildcard ("*") rules.
if (rules.items.len > 0) {
freeRulesInList(allocator, wildcard_rules.items);
return try rules.toOwnedSlice(allocator);
} else {
freeRulesInList(allocator, rules.items);
return try wildcard_rules.toOwnedSlice(allocator);
}
}
pub fn fromBytes(allocator: std.mem.Allocator, user_agent: []const u8, bytes: []const u8) !Robots {
const rules = try parseRulesWithUserAgent(allocator, user_agent, bytes);
return .{ .rules = rules };
}
pub fn deinit(self: *Robots, allocator: std.mem.Allocator) void {
freeRulesInList(allocator, self.rules);
allocator.free(self.rules);
}
fn matchPatternRecursive(pattern: []const u8, path: []const u8, exact_match: bool) bool {
if (pattern.len == 0) return true;
const star_pos = std.mem.indexOfScalar(u8, pattern, '*') orelse {
if (exact_match) {
// If we end in '$', we must be exactly equal.
return std.mem.eql(u8, path, pattern);
} else {
// Otherwise, we are just a prefix.
return std.mem.startsWith(u8, path, pattern);
}
};
// Ensure the prefix before the '*' matches.
if (!std.mem.startsWith(u8, path, pattern[0..star_pos])) {
return false;
}
const suffix_pattern = pattern[star_pos + 1 ..];
if (suffix_pattern.len == 0) return true;
var i: usize = star_pos;
while (i <= path.len) : (i += 1) {
if (matchPatternRecursive(suffix_pattern, path[i..], exact_match)) {
return true;
}
}
return false;
}
/// There are rules for how the pattern in robots.txt should be matched.
///
/// * should match 0 or more of any character.
/// $ should signify the end of a path, making it exact.
/// otherwise, it is a prefix path.
fn matchPattern(pattern: []const u8, path: []const u8) ?usize {
if (pattern.len == 0) return 0;
const exact_match = pattern[pattern.len - 1] == '$';
const inner_pattern = if (exact_match) pattern[0 .. pattern.len - 1] else pattern;
if (matchPatternRecursive(
inner_pattern,
path,
exact_match,
)) return pattern.len else return null;
}
pub fn isAllowed(self: *const Robots, path: []const u8) bool {
const rules = self.rules;
var longest_match_len: usize = 0;
var is_allowed_result = true;
for (rules) |rule| {
switch (rule) {
.allow => |pattern| {
if (matchPattern(pattern, path)) |len| {
// Longest or Last Wins.
if (len >= longest_match_len) {
longest_match_len = len;
is_allowed_result = true;
}
}
},
.disallow => |pattern| {
if (pattern.len == 0) continue;
if (matchPattern(pattern, path)) |len| {
// Longest or Last Wins.
if (len >= longest_match_len) {
longest_match_len = len;
is_allowed_result = false;
}
}
},
}
}
return is_allowed_result;
}
test "Robots: simple robots.txt" {
const allocator = std.testing.allocator;
const file =
\\User-agent: *
\\Disallow: /private/
\\Allow: /public/
\\
\\User-agent: Googlebot
\\Disallow: /admin/
\\
;
const rules = try parseRulesWithUserAgent(allocator, "GoogleBot", file);
defer {
freeRulesInList(allocator, rules);
allocator.free(rules);
}
try std.testing.expectEqual(1, rules.len);
try std.testing.expectEqualStrings("/admin/", rules[0].disallow);
}
test "Robots: matchPattern - simple prefix" {
try std.testing.expect(matchPattern("/admin", "/admin/page") != null);
try std.testing.expect(matchPattern("/admin", "/admin") != null);
try std.testing.expect(matchPattern("/admin", "/other") == null);
try std.testing.expect(matchPattern("/admin/page", "/admin") == null);
}
test "Robots: matchPattern - single wildcard" {
try std.testing.expect(matchPattern("/admin/*", "/admin/") != null);
try std.testing.expect(matchPattern("/admin/*", "/admin/page") != null);
try std.testing.expect(matchPattern("/admin/*", "/admin/page/subpage") != null);
try std.testing.expect(matchPattern("/admin/*", "/other/page") == null);
}
test "Robots: matchPattern - wildcard in middle" {
try std.testing.expect(matchPattern("/abc/*/xyz", "/abc/def/xyz") != null);
try std.testing.expect(matchPattern("/abc/*/xyz", "/abc/def/ghi/xyz") != null);
try std.testing.expect(matchPattern("/abc/*/xyz", "/abc/def") == null);
try std.testing.expect(matchPattern("/abc/*/xyz", "/other/def/xyz") == null);
}
test "Robots: matchPattern - complex wildcard case" {
try std.testing.expect(matchPattern("/abc/*/def/xyz", "/abc/def/def/xyz") != null);
try std.testing.expect(matchPattern("/abc/*/def/xyz", "/abc/ANYTHING/def/xyz") != null);
}
test "Robots: matchPattern - multiple wildcards" {
try std.testing.expect(matchPattern("/a/*/b/*/c", "/a/x/b/y/c") != null);
try std.testing.expect(matchPattern("/a/*/b/*/c", "/a/x/y/b/z/w/c") != null);
try std.testing.expect(matchPattern("/*.php", "/index.php") != null);
try std.testing.expect(matchPattern("/*.php", "/admin/index.php") != null);
}
test "Robots: matchPattern - end anchor" {
try std.testing.expect(matchPattern("/*.php$", "/index.php") != null);
try std.testing.expect(matchPattern("/*.php$", "/index.php?param=value") == null);
try std.testing.expect(matchPattern("/admin$", "/admin") != null);
try std.testing.expect(matchPattern("/admin$", "/admin/") == null);
try std.testing.expect(matchPattern("/fish$", "/fish") != null);
try std.testing.expect(matchPattern("/fish$", "/fishheads") == null);
}
test "Robots: matchPattern - wildcard with extension" {
try std.testing.expect(matchPattern("/fish*.php", "/fish.php") != null);
try std.testing.expect(matchPattern("/fish*.php", "/fishheads.php") != null);
try std.testing.expect(matchPattern("/fish*.php", "/fish/salmon.php") != null);
try std.testing.expect(matchPattern("/fish*.php", "/fish.asp") == null);
}
test "Robots: matchPattern - empty and edge cases" {
try std.testing.expect(matchPattern("", "/anything") != null);
try std.testing.expect(matchPattern("/", "/") != null);
try std.testing.expect(matchPattern("*", "/anything") != null);
try std.testing.expect(matchPattern("/*", "/anything") != null);
try std.testing.expect(matchPattern("$", "") != null);
}
test "Robots: matchPattern - real world examples" {
try std.testing.expect(matchPattern("/", "/anything") != null);
try std.testing.expect(matchPattern("/admin/", "/admin/page") != null);
try std.testing.expect(matchPattern("/admin/", "/public/page") == null);
try std.testing.expect(matchPattern("/*.pdf$", "/document.pdf") != null);
try std.testing.expect(matchPattern("/*.pdf$", "/document.pdf.bak") == null);
try std.testing.expect(matchPattern("/*?", "/page?param=value") != null);
try std.testing.expect(matchPattern("/*?", "/page") == null);
}
test "Robots: isAllowed - basic allow/disallow" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "MyBot",
\\User-agent: MyBot
\\Disallow: /admin/
\\Allow: /public/
\\
);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/") == true);
try std.testing.expect(robots.isAllowed("/public/page") == true);
try std.testing.expect(robots.isAllowed("/admin/secret") == false);
try std.testing.expect(robots.isAllowed("/other/page") == true);
}
test "Robots: isAllowed - longest match wins" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "TestBot",
\\User-agent: TestBot
\\Disallow: /admin/
\\Allow: /admin/public/
\\
);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/admin/secret") == false);
try std.testing.expect(robots.isAllowed("/admin/public/page") == true);
try std.testing.expect(robots.isAllowed("/admin/public/") == true);
}
test "Robots: isAllowed - specific user-agent vs wildcard" {
const allocator = std.testing.allocator;
var robots1 = try Robots.fromBytes(allocator, "Googlebot",
\\User-agent: Googlebot
\\Disallow: /private/
\\
\\User-agent: *
\\Disallow: /admin/
\\
);
defer robots1.deinit(allocator);
try std.testing.expect(robots1.isAllowed("/private/page") == false);
try std.testing.expect(robots1.isAllowed("/admin/page") == true);
// Test with other bot (should use wildcard)
var robots2 = try Robots.fromBytes(allocator, "OtherBot",
\\User-agent: Googlebot
\\Disallow: /private/
\\
\\User-agent: *
\\Disallow: /admin/
\\
);
defer robots2.deinit(allocator);
try std.testing.expect(robots2.isAllowed("/private/page") == true);
try std.testing.expect(robots2.isAllowed("/admin/page") == false);
}
test "Robots: isAllowed - case insensitive user-agent" {
const allocator = std.testing.allocator;
var robots1 = try Robots.fromBytes(allocator, "googlebot",
\\User-agent: GoogleBot
\\Disallow: /private/
\\
);
defer robots1.deinit(allocator);
try std.testing.expect(robots1.isAllowed("/private/") == false);
var robots2 = try Robots.fromBytes(allocator, "GOOGLEBOT",
\\User-agent: GoogleBot
\\Disallow: /private/
\\
);
defer robots2.deinit(allocator);
try std.testing.expect(robots2.isAllowed("/private/") == false);
var robots3 = try Robots.fromBytes(allocator, "GoOgLeBoT",
\\User-agent: GoogleBot
\\Disallow: /private/
\\
);
defer robots3.deinit(allocator);
try std.testing.expect(robots3.isAllowed("/private/") == false);
}
test "Robots: isAllowed - merged rules for same agent" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "Googlebot",
\\User-agent: Googlebot
\\Disallow: /admin/
\\
\\User-agent: Googlebot
\\Disallow: /private/
\\
);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/admin/page") == false);
try std.testing.expect(robots.isAllowed("/private/page") == false);
try std.testing.expect(robots.isAllowed("/public/page") == true);
}
test "Robots: isAllowed - wildcards in patterns" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "Bot",
\\User-agent: Bot
\\Disallow: /*.php$
\\Allow: /index.php$
\\
);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/page.php") == false);
try std.testing.expect(robots.isAllowed("/index.php") == true);
try std.testing.expect(robots.isAllowed("/page.php?param=1") == true);
try std.testing.expect(robots.isAllowed("/page.html") == true);
}
test "Robots: isAllowed - empty disallow allows everything" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "Bot",
\\User-agent: Bot
\\Disallow:
\\
);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/anything") == true);
try std.testing.expect(robots.isAllowed("/") == true);
}
test "Robots: isAllowed - no rules" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "Bot", "");
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/anything") == true);
}
test "Robots: isAllowed - disallow all" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "Bot",
\\User-agent: Bot
\\Disallow: /
\\
);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/") == false);
try std.testing.expect(robots.isAllowed("/anything") == false);
try std.testing.expect(robots.isAllowed("/admin/page") == false);
}
test "Robots: isAllowed - multiple user-agents in same entry" {
const allocator = std.testing.allocator;
var robots1 = try Robots.fromBytes(allocator, "Googlebot",
\\User-agent: Googlebot
\\User-agent: Bingbot
\\Disallow: /private/
\\
);
defer robots1.deinit(allocator);
try std.testing.expect(robots1.isAllowed("/private/") == false);
var robots2 = try Robots.fromBytes(allocator, "Bingbot",
\\User-agent: Googlebot
\\User-agent: Bingbot
\\Disallow: /private/
\\
);
defer robots2.deinit(allocator);
try std.testing.expect(robots2.isAllowed("/private/") == false);
var robots3 = try Robots.fromBytes(allocator, "OtherBot",
\\User-agent: Googlebot
\\User-agent: Bingbot
\\Disallow: /private/
\\
);
defer robots3.deinit(allocator);
try std.testing.expect(robots3.isAllowed("/private/") == true);
}
test "Robots: isAllowed - wildcard fallback" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "UnknownBot",
\\User-agent: *
\\Disallow: /admin/
\\Allow: /admin/public/
\\
\\User-agent: Googlebot
\\Disallow: /private/
\\
);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/admin/secret") == false);
try std.testing.expect(robots.isAllowed("/admin/public/page") == true);
try std.testing.expect(robots.isAllowed("/private/") == true);
}
test "Robots: isAllowed - complex real-world example" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "MyBot",
\\User-agent: *
\\Disallow: /cgi-bin/
\\Disallow: /tmp/
\\Disallow: /private/
\\
\\User-agent: MyBot
\\Disallow: /admin/
\\Disallow: /*.pdf$
\\Allow: /public/*.pdf$
\\
);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/") == true);
try std.testing.expect(robots.isAllowed("/admin/dashboard") == false);
try std.testing.expect(robots.isAllowed("/docs/guide.pdf") == false);
try std.testing.expect(robots.isAllowed("/public/manual.pdf") == true);
try std.testing.expect(robots.isAllowed("/page.html") == true);
try std.testing.expect(robots.isAllowed("/cgi-bin/script.sh") == true);
}
test "Robots: isAllowed - order doesn't matter for same length" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "Bot",
\\User-agent: Bot
\\ # WOW!!
\\Allow: /page
\\Disallow: /page
\\
);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/page") == false);
}
test "Robots: isAllowed - empty file uses wildcard defaults" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "MyBot",
\\User-agent: * # ABCDEF!!!
\\Disallow: /admin/
\\
);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/admin/") == false);
try std.testing.expect(robots.isAllowed("/public/") == true);
}
test "Robots: isAllowed - wildcard entry with multiple user-agents including specific" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "Googlebot",
\\User-agent: *
\\User-agent: Googlebot
\\Disallow: /shared/
\\
);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/shared/") == false);
try std.testing.expect(robots.isAllowed("/other/") == true);
var robots2 = try Robots.fromBytes(allocator, "Bingbot",
\\User-agent: *
\\User-agent: Googlebot
\\Disallow: /shared/
\\
);
defer robots2.deinit(allocator);
try std.testing.expect(robots2.isAllowed("/shared/") == false);
}
test "Robots: isAllowed - specific agent appears after wildcard in entry" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "MyBot",
\\User-agent: *
\\User-agent: MyBot
\\User-agent: Bingbot
\\Disallow: /admin/
\\Allow: /admin/public/
\\
);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/admin/secret") == false);
try std.testing.expect(robots.isAllowed("/admin/public/page") == true);
}
test "Robots: isAllowed - wildcard should not override specific entry" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "Googlebot",
\\User-agent: Googlebot
\\Disallow: /private/
\\
\\User-agent: *
\\User-agent: Googlebot
\\Disallow: /admin/
\\
);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/private/") == false);
try std.testing.expect(robots.isAllowed("/admin/") == false);
}
test "Robots: isAllowed - Google's real robots.txt" {
const allocator = std.testing.allocator;
// Simplified version of google.com/robots.txt
const google_robots =
\\User-agent: *
\\User-agent: Yandex
\\Disallow: /search
\\Allow: /search/about
\\Allow: /search/howsearchworks
\\Disallow: /imgres
\\Disallow: /m?
\\Disallow: /m/
\\Allow: /m/finance
\\Disallow: /maps/
\\Allow: /maps/$
\\Allow: /maps/@
\\Allow: /maps/dir/
\\Disallow: /shopping?
\\Allow: /shopping?udm=28$
\\
\\User-agent: AdsBot-Google
\\Disallow: /maps/api/js/
\\Allow: /maps/api/js
\\Disallow: /maps/api/staticmap
\\
\\User-agent: Yandex
\\Disallow: /about/careers/applications/jobs/results
\\
\\User-agent: facebookexternalhit
\\User-agent: Twitterbot
\\Allow: /imgres
\\Allow: /search
\\Disallow: /groups
\\Disallow: /m/
\\
;
var regular_bot = try Robots.fromBytes(allocator, "Googlebot", google_robots);
defer regular_bot.deinit(allocator);
try std.testing.expect(regular_bot.isAllowed("/") == true);
try std.testing.expect(regular_bot.isAllowed("/search") == false);
try std.testing.expect(regular_bot.isAllowed("/search/about") == true);
try std.testing.expect(regular_bot.isAllowed("/search/howsearchworks") == true);
try std.testing.expect(regular_bot.isAllowed("/imgres") == false);
try std.testing.expect(regular_bot.isAllowed("/m/finance") == true);
try std.testing.expect(regular_bot.isAllowed("/m/other") == false);
try std.testing.expect(regular_bot.isAllowed("/maps/") == true);
try std.testing.expect(regular_bot.isAllowed("/maps/@") == true);
try std.testing.expect(regular_bot.isAllowed("/shopping?udm=28") == true);
try std.testing.expect(regular_bot.isAllowed("/shopping?udm=28&extra") == false);
var adsbot = try Robots.fromBytes(allocator, "AdsBot-Google", google_robots);
defer adsbot.deinit(allocator);
try std.testing.expect(adsbot.isAllowed("/maps/api/js") == true);
try std.testing.expect(adsbot.isAllowed("/maps/api/js/") == false);
try std.testing.expect(adsbot.isAllowed("/maps/api/staticmap") == false);
var twitterbot = try Robots.fromBytes(allocator, "Twitterbot", google_robots);
defer twitterbot.deinit(allocator);
try std.testing.expect(twitterbot.isAllowed("/imgres") == true);
try std.testing.expect(twitterbot.isAllowed("/search") == true);
try std.testing.expect(twitterbot.isAllowed("/groups") == false);
try std.testing.expect(twitterbot.isAllowed("/m/") == false);
}
test "Robots: user-agent after rules starts new entry" {
const allocator = std.testing.allocator;
const file =
\\User-agent: Bot1
\\User-agent: Bot2
\\Disallow: /admin/
\\Allow: /public/
\\User-agent: Bot3
\\Disallow: /private/
\\
;
var robots1 = try Robots.fromBytes(allocator, "Bot1", file);
defer robots1.deinit(allocator);
try std.testing.expect(robots1.isAllowed("/admin/") == false);
try std.testing.expect(robots1.isAllowed("/public/") == true);
try std.testing.expect(robots1.isAllowed("/private/") == true);
var robots2 = try Robots.fromBytes(allocator, "Bot2", file);
defer robots2.deinit(allocator);
try std.testing.expect(robots2.isAllowed("/admin/") == false);
try std.testing.expect(robots2.isAllowed("/public/") == true);
try std.testing.expect(robots2.isAllowed("/private/") == true);
var robots3 = try Robots.fromBytes(allocator, "Bot3", file);
defer robots3.deinit(allocator);
try std.testing.expect(robots3.isAllowed("/admin/") == true);
try std.testing.expect(robots3.isAllowed("/public/") == true);
try std.testing.expect(robots3.isAllowed("/private/") == false);
}
test "Robots: blank lines don't end entries" {
const allocator = std.testing.allocator;
const file =
\\User-agent: MyBot
\\Disallow: /admin/
\\
\\
\\Allow: /public/
\\
;
var robots = try Robots.fromBytes(allocator, "MyBot", file);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/admin/") == false);
try std.testing.expect(robots.isAllowed("/public/") == true);
}

View File

@@ -31,7 +31,7 @@ const Http = @import("../http/Http.zig");
const Element = @import("webapi/Element.zig");
const Allocator = std.mem.Allocator;
const ArrayListUnmanaged = std.ArrayListUnmanaged;
const ArrayList = std.ArrayList;
const IS_DEBUG = builtin.mode == .Debug;
@@ -138,6 +138,12 @@ fn clearList(list: *std.DoublyLinkedList) void {
}
}
pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !Http.Headers {
var headers = try self.client.newHeaders();
try self.page.headersForRequest(self.page.arena, url, &headers);
return headers;
}
pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_element: *Element.Html.Script, comptime ctx: []const u8) !void {
if (script_element._executed) {
// If a script tag gets dynamically created and added to the dom:
@@ -152,14 +158,14 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
script_element._executed = true;
const element = script_element.asElement();
if (element.getAttributeSafe("nomodule") != null) {
if (element.getAttributeSafe(comptime .wrap("nomodule")) != null) {
// these scripts should only be loaded if we don't support modules
// but since we do support modules, we can just skip them.
return;
}
const kind: Script.Kind = blk: {
const script_type = element.getAttributeSafe("type") orelse break :blk .javascript;
const script_type = element.getAttributeSafe(comptime .wrap("type")) orelse break :blk .javascript;
if (script_type.len == 0) {
break :blk .javascript;
}
@@ -186,7 +192,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
var source: Script.Source = undefined;
var remote_url: ?[:0]const u8 = null;
const base_url = page.base();
if (element.getAttributeSafe("src")) |src| {
if (element.getAttributeSafe(comptime .wrap("src"))) |src| {
if (try parseDataURI(page.arena, src)) |data_uri| {
source = .{ .@"inline" = data_uri };
} else {
@@ -217,12 +223,12 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
break :blk if (kind == .module) .@"defer" else .normal;
}
if (element.getAttributeSafe("async") != null) {
if (element.getAttributeSafe(comptime .wrap("async")) != null) {
break :blk .async;
}
// Check for defer or module (before checking dynamic script default)
if (kind == .module or element.getAttributeSafe("defer") != null) {
if (kind == .module or element.getAttributeSafe(comptime .wrap("defer")) != null) {
break :blk .@"defer";
}
@@ -252,17 +258,15 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
script.deinit(true);
}
var headers = try self.client.newHeaders();
try page.requestCookie(.{}).headersForRequest(page.arena, url, &headers);
try self.client.request(.{
.url = url,
.ctx = script,
.method = .GET,
.headers = headers,
.headers = try self.getHeaders(url),
.blocking = is_blocking,
.cookie_jar = &page._session.cookie_jar,
.resource_type = .script,
.notification = page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
@@ -271,11 +275,15 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
});
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.ctx = ctx,
.url = remote_url.?,
.element = element,
.stack = page.js.stackTrace() catch "???",
.stack = ls.local.stackTrace() catch "???",
});
}
}
@@ -353,15 +361,16 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.manager = self,
};
var headers = try self.client.newHeaders();
try self.page.requestCookie(.{}).headersForRequest(self.page.arena, url, &headers);
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.url = url,
.ctx = "module",
.referrer = referrer,
.stack = self.page.js.stackTrace() catch "???",
.stack = ls.local.stackTrace() catch "???",
});
}
@@ -369,9 +378,10 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.url = url,
.ctx = script,
.method = .GET,
.headers = headers,
.headers = try self.getHeaders(url),
.cookie_jar = &self.page._session.cookie_jar,
.resource_type = .script,
.notification = self.page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
@@ -444,15 +454,16 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
} },
};
var headers = try self.client.newHeaders();
try self.page.requestCookie(.{}).headersForRequest(self.page.arena, url, &headers);
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.url = url,
.ctx = "dynamic module",
.referrer = referrer,
.stack = self.page.js.stackTrace() catch "???",
.stack = ls.local.stackTrace() catch "???",
});
}
@@ -468,10 +479,11 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
try self.client.request(.{
.url = url,
.method = .GET,
.headers = headers,
.headers = try self.getHeaders(url),
.ctx = script,
.resource_type = .script,
.cookie_jar = &self.page._session.cookie_jar,
.notification = self.page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
@@ -622,7 +634,7 @@ pub const Script = struct {
const Source = union(enum) {
@"inline": []const u8,
remote: std.ArrayListUnmanaged(u8),
remote: std.ArrayList(u8),
fn content(self: Source) []const u8 {
return switch (self) {
@@ -651,7 +663,7 @@ pub const Script = struct {
log.debug(.http, "script fetch start", .{ .req = transfer });
}
fn headerCallback(transfer: *Http.Transfer) !void {
fn headerCallback(transfer: *Http.Transfer) !bool {
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
const header = &transfer.response_header.?;
self.status = header.status;
@@ -661,7 +673,7 @@ pub const Script = struct {
.status = header.status,
.content_type = header.contentType(),
});
return;
return false;
}
if (comptime IS_DEBUG) {
@@ -672,16 +684,13 @@ pub const Script = struct {
});
}
// If this isn't true, then we'll likely leak memory. If you don't
// set `CURLOPT_SUPPRESS_CONNECT_HEADERS` and CONNECT to a proxy, this
// will fail. This assertion exists to catch incorrect assumptions about
// how libcurl works, or about how we've configured it.
lp.assert(self.source.remote.capacity == 0, "ScriptManager.HeaderCallback", .{ .capacity = self.source.remote.capacity });
var buffer = self.manager.buffer_pool.get();
if (transfer.getContentLength()) |cl| {
try buffer.ensureTotalCapacity(self.manager.allocator, cl);
}
self.source = .{ .remote = buffer };
return true;
}
fn dataCallback(transfer: *Http.Transfer, data: []const u8) !void {
@@ -721,7 +730,7 @@ pub const Script = struct {
log.warn(.http, "script fetch error", .{
.err = err,
.req = self.url,
.mode = self.mode,
.mode = std.meta.activeTag(self.mode),
.kind = self.kind,
.status = self.status,
});
@@ -741,9 +750,13 @@ pub const Script = struct {
return;
}
if (self.mode == .import) {
const entry = self.manager.imported_modules.getPtr(self.url).?;
entry.state = .err;
switch (self.mode) {
.import_async => |ia| ia.callback(ia.data, error.FailedToLoad),
.import => {
const entry = manager.imported_modules.getPtr(self.url).?;
entry.state = .err;
},
else => {},
}
self.deinit(true);
manager.evaluate();
@@ -785,6 +798,12 @@ pub const Script = struct {
.cacheable = cacheable,
});
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
const local = &ls.local;
// Handle importmap special case here: the content is a JSON containing
// imports.
if (self.kind == .importmap) {
@@ -795,25 +814,24 @@ pub const Script = struct {
.kind = self.kind,
.cacheable = cacheable,
});
self.executeCallback("error", script_element._on_error, page);
self.executeCallback("error", local.toLocal(script_element._on_error), page);
return;
};
self.executeCallback("load", script_element._on_load, page);
self.executeCallback("load", local.toLocal(script_element._on_load), page);
return;
}
const js_context = page.js;
var try_catch: js.TryCatch = undefined;
try_catch.init(js_context);
try_catch.init(local);
defer try_catch.deinit();
const success = blk: {
const content = self.source.content();
switch (self.kind) {
.javascript => _ = js_context.eval(content, url) catch break :blk false,
.javascript => _ = local.eval(content, url) catch break :blk false,
.module => {
// We don't care about waiting for the evaluation here.
js_context.module(false, content, url, cacheable) catch break :blk false;
page.js.module(false, local, content, url, cacheable) catch break :blk false;
},
.importmap => unreachable, // handled before the try/catch.
}
@@ -826,14 +844,14 @@ pub const Script = struct {
defer {
// We should run microtasks even if script execution fails.
page.js.runMicrotasks();
_ = page.scheduler.run() catch |err| {
local.runMicrotasks();
_ = page.js.scheduler.run() catch |err| {
log.err(.page, "scheduler", .{ .err = err });
};
}
if (success) {
self.executeCallback("load", script_element._on_load, page);
self.executeCallback("load", local.toLocal(script_element._on_load), page);
return;
}
@@ -844,15 +862,14 @@ pub const Script = struct {
.cacheable = cacheable,
});
self.executeCallback("error", script_element._on_error, page);
self.executeCallback("error", local.toLocal(script_element._on_error), page);
}
fn executeCallback(self: *const Script, comptime typ: []const u8, cb_: ?js.Function.Global, page: *Page) void {
const cb_global = cb_ orelse return;
const cb = cb_global.local();
fn executeCallback(self: *const Script, comptime typ: []const u8, cb_: ?js.Function, page: *Page) void {
const cb = cb_ orelse return;
const Event = @import("webapi/Event.zig");
const event = Event.initTrusted(typ, .{}, page) catch |err| {
const event = Event.initTrusted(comptime .wrap(typ), .{}, page) catch |err| {
log.warn(.js, "script internal callback", .{
.url = self.url,
.type = typ,
@@ -860,6 +877,7 @@ pub const Script = struct {
});
return;
};
defer if (!event._v8_handoff) event.deinit(false);
var caught: js.TryCatch.Caught = undefined;
cb.tryCall(void, .{event}, &caught) catch {
@@ -883,7 +901,7 @@ const BufferPool = struct {
const Container = struct {
node: List.Node,
buf: std.ArrayListUnmanaged(u8),
buf: std.ArrayList(u8),
};
fn init(allocator: Allocator, max_concurrent_transfers: u8) BufferPool {
@@ -908,7 +926,7 @@ const BufferPool = struct {
self.mem_pool.deinit();
}
fn get(self: *BufferPool) std.ArrayListUnmanaged(u8) {
fn get(self: *BufferPool) std.ArrayList(u8) {
const node = self.available.popFirst() orelse {
// return a new buffer
return .{};
@@ -920,7 +938,7 @@ const BufferPool = struct {
return container.buf;
}
fn release(self: *BufferPool, buffer: ArrayListUnmanaged(u8)) void {
fn release(self: *BufferPool, buffer: ArrayList(u8)) void {
// create mutable copy
var b = buffer;

View File

@@ -28,6 +28,7 @@ const History = @import("webapi/History.zig");
const Page = @import("Page.zig");
const Browser = @import("Browser.zig");
const Notification = @import("../Notification.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -39,6 +40,7 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
const Session = @This();
browser: *Browser,
notification: *Notification,
// Used to create our Inspector and in the BrowserContext.
arena: Allocator,
@@ -53,30 +55,27 @@ arena: Allocator,
// page and start another.
transfer_arena: Allocator,
executor: js.ExecutionWorld,
cookie_jar: storage.Cookie.Jar,
storage_shed: storage.Shed,
history: History,
navigation: Navigation,
page: ?*Page = null,
pub fn init(self: *Session, browser: *Browser) !void {
var executor = try browser.env.newExecutionWorld();
errdefer executor.deinit();
page: ?Page,
pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void {
const allocator = browser.app.allocator;
const session_allocator = browser.session_arena.allocator();
self.* = .{
.browser = browser,
.executor = executor,
.page = null,
.history = .{},
.navigation = .{},
.storage_shed = .{},
.browser = browser,
.notification = notification,
.arena = session_allocator,
.cookie_jar = storage.Cookie.Jar.init(allocator),
.navigation = .{},
.history = .{},
.transfer_arena = browser.transfer_arena.allocator(),
};
}
@@ -87,7 +86,6 @@ pub fn deinit(self: *Session) void {
}
self.cookie_jar.deinit();
self.storage_shed.deinit(self.browser.app.allocator);
self.executor.deinit();
}
// NOTE: the caller is not the owner of the returned value,
@@ -95,11 +93,11 @@ pub fn deinit(self: *Session) void {
pub fn createPage(self: *Session) !*Page {
lp.assert(self.page == null, "Session.createPage - page not null", .{});
const page_arena = &self.browser.page_arena;
_ = page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
_ = self.browser.page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
self.page = try Page.init(page_arena.allocator(), self.browser.call_arena.allocator(), self);
const page = self.page.?;
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, self);
// Creates a new NavigationEventTarget for this page.
try self.navigation.onNewPage(page);
@@ -109,14 +107,14 @@ pub fn createPage(self: *Session) !*Page {
}
// start JS env
// Inform CDP the main page has been created such that additional context for other Worlds can be created as well
self.browser.notification.dispatch(.page_created, page);
self.notification.dispatch(.page_created, page);
return page;
}
pub fn removePage(self: *Session) void {
// Inform CDP the page is going to be removed, allowing other worlds to remove themselves before the main one
self.browser.notification.dispatch(.page_remove, .{});
self.notification.dispatch(.page_remove, .{});
lp.assert(self.page != null, "Session.removePage - page is null", .{});
self.page.?.deinit();
@@ -130,22 +128,29 @@ pub fn removePage(self: *Session) void {
}
pub fn currentPage(self: *Session) ?*Page {
return self.page orelse return null;
return &(self.page orelse return null);
}
pub const WaitResult = enum {
done,
no_page,
cdp_socket,
navigate,
};
pub fn wait(self: *Session, wait_ms: u32) WaitResult {
while (true) {
const page = self.page orelse return .no_page;
switch (page.wait(wait_ms)) {
.navigate => self.processScheduledNavigation() catch return .done,
else => |result| return result,
if (self.page) |*page| {
switch (page.wait(wait_ms)) {
.done => {
if (page._queued_navigation == null) {
return .done;
}
self.processScheduledNavigation() catch return .done;
},
else => |result| return result,
}
} else {
return .no_page;
}
// if we've successfull navigated, we'll give the new page another
// page.wait(wait_ms)
@@ -153,24 +158,32 @@ pub fn wait(self: *Session, wait_ms: u32) WaitResult {
}
fn processScheduledNavigation(self: *Session) !void {
const qn = self.page.?._queued_navigation.?;
defer _ = self.browser.transfer_arena.reset(.{ .retain_with_limit = 8 * 1024 });
const url, const opts = blk: {
const qn = self.page.?._queued_navigation.?;
// qn might not be safe to use after self.removePage is called, hence
// this block;
const url = qn.url;
const opts = qn.opts;
// This was already aborted on the page, but it would be pretty
// bad if old requests went to the new page, so let's make double sure
self.browser.http_client.abort();
self.removePage();
// This was already aborted on the page, but it would be pretty
// bad if old requests went to the new page, so let's make double sure
self.browser.http_client.abort();
self.removePage();
break :blk .{ url, opts };
};
const page = self.createPage() catch |err| {
log.err(.browser, "queued navigation page error", .{
.err = err,
.url = qn.url,
.url = url,
});
return err;
};
page.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued navigation error", .{ .err = err, .url = qn.url });
page.navigate(url, opts) catch |err| {
log.err(.browser, "queued navigation error", .{ .err = err, .url = url });
return err;
};
}

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -17,7 +17,6 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const Allocator = std.mem.Allocator;
const ResolveOpts = struct {
@@ -77,8 +76,9 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
}
// trailing space so that we always have space to append the null terminator
var out = try std.mem.join(allocator, "", &.{ normalized_base, "/", path, " " });
const end = out.len - 1;
// and so that we can compare the next two characters without needing to length check
var out = try std.mem.join(allocator, "", &.{ normalized_base, "/", path, " " });
const end = out.len - 2;
const path_marker = path_start + 1;
@@ -88,33 +88,39 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
var in_i: usize = 0;
var out_i: usize = 0;
while (in_i < end) {
if (std.mem.startsWith(u8, out[in_i..], "./")) {
in_i += 2;
continue;
}
if (std.mem.startsWith(u8, out[in_i..], "../")) {
lp.assert(out[out_i - 1] == '/', "URL.resolve", .{ .out = out });
if (out_i > path_marker) {
// go back before the /
out_i -= 2;
while (out_i > 1 and out[out_i - 1] != '/') {
out_i -= 1;
}
} else {
// if out_i == path_marker, than we've reached the start of
// the path. We can't ../ any more. E.g.:
// http://www.example.com/../hello.
// You might think that's an error, but, at least with
// new URL('../hello', 'http://www.example.com/')
// it just ignores the extra ../
if (out[in_i] == '.' and (out_i == 0 or out[out_i - 1] == '/')) {
if (out[in_i + 1] == '/') { // always safe, because we added a whitespace
// /./
in_i += 2;
continue;
}
if (out[in_i + 1] == '.' and out[in_i + 2] == '/') { // always safe, because we added two whitespaces
// /../
if (out_i > path_marker) {
// go back before the /
out_i -= 2;
while (out_i > 1 and out[out_i - 1] != '/') {
out_i -= 1;
}
} else {
// if out_i == path_marker, than we've reached the start of
// the path. We can't ../ any more. E.g.:
// http://www.example.com/../hello.
// You might think that's an error, but, at least with
// new URL('../hello', 'http://www.example.com/')
// it just ignores the extra ../
}
in_i += 3;
continue;
}
if (in_i == end - 1) {
// ignore trailing dot
break;
}
in_i += 3;
continue;
}
out[out_i] = out[in_i];
const c = out[in_i];
out[out_i] = c;
in_i += 1;
out_i += 1;
}
@@ -496,6 +502,16 @@ pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []cons
return buf.items[0 .. buf.items.len - 1 :0];
}
pub fn getRobotsUrl(arena: Allocator, url: [:0]const u8) ![:0]const u8 {
const origin = try getOrigin(arena, url) orelse return error.NoOrigin;
return try std.fmt.allocPrintSentinel(
arena,
"{s}/robots.txt",
.{origin},
0,
);
}
const testing = @import("../testing.zig");
test "URL: isCompleteHTTPUrl" {
try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about"));
@@ -542,6 +558,21 @@ test "URL: resolve" {
};
const cases = [_]Case{
.{
.base = "https://example/dir",
.path = "abc../test",
.expected = "https://example/abc../test",
},
.{
.base = "https://example/dir",
.path = "abc.",
.expected = "https://example/abc.",
},
.{
.base = "https://example/dir",
.path = "abc/.",
.expected = "https://example/abc/",
},
.{
.base = "https://example/xyz/abc/123",
.path = "something.js",
@@ -757,3 +788,31 @@ test "URL: concatQueryString" {
try testing.expectEqual("https://www.lightpanda.io/index?1=2&a=b", url);
}
}
test "URL: getRobotsUrl" {
defer testing.reset();
const arena = testing.arena_allocator;
{
const url = try getRobotsUrl(arena, "https://www.lightpanda.io");
try testing.expectEqual("https://www.lightpanda.io/robots.txt", url);
}
{
const url = try getRobotsUrl(arena, "https://www.lightpanda.io/some/path");
try testing.expectString("https://www.lightpanda.io/robots.txt", url);
}
{
const url = try getRobotsUrl(arena, "https://www.lightpanda.io:8080/page");
try testing.expectString("https://www.lightpanda.io:8080/robots.txt", url);
}
{
const url = try getRobotsUrl(arena, "http://example.com/deep/nested/path?query=value#fragment");
try testing.expectString("http://example.com/robots.txt", url);
}
{
const url = try getRobotsUrl(arena, "https://user:pass@example.com/page");
try testing.expectString("https://example.com/robots.txt", url);
}
}

View File

@@ -51,11 +51,22 @@ pub const Opts = struct {
pub fn root(doc: *Node.Document, opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void {
if (doc.is(Node.Document.HTMLDocument)) |html_doc| {
try writer.writeAll("<!DOCTYPE html>");
blk: {
// Ideally we just render the doctype which is part of the document
if (doc.asNode().firstChild()) |first| {
if (first._type == .document_type) {
break :blk;
}
}
// But if the doc has no child, or the first child isn't a doctype
// well force it.
try writer.writeAll("<!DOCTYPE html>");
}
if (opts.with_base) {
const parent = if (html_doc.getHead()) |head| head.asNode() else doc.asNode();
const base = try doc.createElement("base", null, page);
try base.setAttributeSafe("base", page.base(), page);
try base.setAttributeSafe(comptime .wrap("base"), .wrap(page.base()), page);
_ = try parent.insertBefore(base.asNode(), parent.firstChild(), page);
}
}
@@ -99,7 +110,7 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri
// to render that "active" content, so when we're trying to render
// it, we don't want to skip it.
if ((comptime force_slot == false) and opts.shadow == .rendered) {
if (el.getAttributeSafe("slot")) |_| {
if (el.getAttributeSafe(comptime .wrap("slot"))) |_| {
// Skip - will be rendered by the Slot if it's the active container
return;
}
@@ -242,12 +253,12 @@ fn shouldStripElement(el: *const Node.Element, opts: Opts) bool {
if (std.mem.eql(u8, tag_name, "noscript")) return true;
if (std.mem.eql(u8, tag_name, "link")) {
if (el.getAttributeSafe("as")) |as| {
if (el.getAttributeSafe(comptime .wrap("as"))) |as| {
if (std.mem.eql(u8, as, "script")) return true;
}
if (el.getAttributeSafe("rel")) |rel| {
if (el.getAttributeSafe(comptime .wrap("rel"))) |rel| {
if (std.mem.eql(u8, rel, "modulepreload") or std.mem.eql(u8, rel, "preload")) {
if (el.getAttributeSafe("as")) |as| {
if (el.getAttributeSafe(comptime .wrap("as"))) |as| {
if (std.mem.eql(u8, as, "script")) return true;
}
}
@@ -259,7 +270,7 @@ fn shouldStripElement(el: *const Node.Element, opts: Opts) bool {
if (std.mem.eql(u8, tag_name, "style")) return true;
if (std.mem.eql(u8, tag_name, "link")) {
if (el.getAttributeSafe("rel")) |rel| {
if (el.getAttributeSafe(comptime .wrap("rel"))) |rel| {
if (std.mem.eql(u8, rel, "stylesheet")) return true;
}
}

View File

@@ -22,7 +22,7 @@ const v8 = js.v8;
const Array = @This();
ctx: *js.Context,
local: *const js.Local,
handle: *const v8.Array,
pub fn len(self: Array) usize {
@@ -30,39 +30,37 @@ pub fn len(self: Array) usize {
}
pub fn get(self: Array, index: u32) !js.Value {
const ctx = self.ctx;
const ctx = self.local.ctx;
const idx = js.Integer.init(ctx.isolate.handle, index);
const handle = v8.v8__Object__Get(@ptrCast(self.handle), ctx.handle, idx.handle) orelse {
const handle = v8.v8__Object__Get(@ptrCast(self.handle), self.local.handle, idx.handle) orelse {
return error.JsException;
};
return .{
.ctx = self.ctx,
.local = self.local,
.handle = handle,
};
}
pub fn set(self: Array, index: u32, value: anytype, comptime opts: js.bridge.Caller.CallOpts) !bool {
const ctx = self.ctx;
const js_value = try ctx.zigValueToJs(value, opts);
pub fn set(self: Array, index: u32, value: anytype, comptime opts: js.Caller.CallOpts) !bool {
const js_value = try self.local.zigValueToJs(value, opts);
var out: v8.MaybeBool = undefined;
v8.v8__Object__SetAtIndex(@ptrCast(self.handle), ctx.handle, index, js_value.handle, &out);
v8.v8__Object__SetAtIndex(@ptrCast(self.handle), self.local.handle, index, js_value.handle, &out);
return out.has_value;
}
pub fn toObject(self: Array) js.Object {
return .{
.ctx = self.ctx,
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
pub fn toValue(self: Array) js.Value {
return .{
.ctx = self.ctx,
.local = self.local,
.handle = @ptrCast(self.handle),
};
}

587
src/browser/js/Caller.zig Normal file
View File

@@ -0,0 +1,587 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const string = @import("../../string.zig");
const Page = @import("../Page.zig");
const js = @import("js.zig");
const Context = @import("Context.zig");
const TaggedOpaque = @import("TaggedOpaque.zig");
const v8 = js.v8;
const ArenaAllocator = std.heap.ArenaAllocator;
const CALL_ARENA_RETAIN = 1024 * 16;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Caller = @This();
local: js.Local,
prev_local: ?*const js.Local,
prev_context: *Context,
// Takes the raw v8 isolate and extracts the context from it.
pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void {
const v8_context_handle = v8.v8__Isolate__GetCurrentContext(v8_isolate);
const embedder_data = v8.v8__Context__GetEmbedderData(v8_context_handle, 1);
var lossless: bool = undefined;
const ctx: *Context = @ptrFromInt(v8.v8__BigInt__Uint64Value(embedder_data, &lossless));
ctx.call_depth += 1;
self.* = Caller{
.local = .{
.ctx = ctx,
.handle = v8_context_handle.?,
.call_arena = ctx.call_arena,
.isolate = .{ .handle = v8_isolate },
},
.prev_local = ctx.local,
.prev_context = ctx.page.js,
};
ctx.page.js = ctx;
ctx.local = &self.local;
}
pub fn deinit(self: *Caller) void {
const ctx = self.local.ctx;
const call_depth = ctx.call_depth - 1;
// Because of callbacks, calls can be nested. Because of this, we
// can't clear the call_arena after _every_ call. Imagine we have
// arr.forEach((i) => { console.log(i); }
//
// First we call forEach. Inside of our forEach call,
// we call console.log. If we reset the call_arena after this call,
// it'll reset it for the `forEach` call after, which might still
// need the data.
//
// Therefore, we keep a call_depth, and only reset the call_arena
// when a top-level (call_depth == 0) function ends.
if (call_depth == 0) {
const arena: *ArenaAllocator = @ptrCast(@alignCast(ctx.call_arena.ptr));
_ = arena.reset(.{ .retain_with_limit = CALL_ARENA_RETAIN });
}
ctx.call_depth = call_depth;
ctx.local = self.prev_local;
ctx.page.js = self.prev_context;
}
pub const CallOpts = struct {
dom_exception: bool = false,
null_as_undefined: bool = false,
as_typed_array: bool = false,
};
pub fn constructor(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = FunctionCallbackInfo{ .handle = handle };
if (!info.isConstructCall()) {
self.handleError(T, @TypeOf(func), error.InvalidArgument, info, opts);
return;
}
self._constructor(func, info) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
};
}
fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void {
const F = @TypeOf(func);
const args = try self.getArgs(F, 0, info);
const res = @call(.auto, func, args);
const ReturnType = @typeInfo(F).@"fn".return_type orelse {
@compileError(@typeName(F) ++ " has a constructor without a return type");
};
const new_this_handle = info.getThis();
var this = js.Object{ .local = &self.local, .handle = new_this_handle };
if (@typeInfo(ReturnType) == .error_union) {
const non_error_res = res catch |err| return err;
this = try self.local.mapZigInstanceToJs(new_this_handle, non_error_res);
} else {
this = try self.local.mapZigInstanceToJs(new_this_handle, res);
}
// If we got back a different object (existing wrapper), copy the prototype
// from new object. (this happens when we're upgrading an CustomElement)
if (this.handle != new_this_handle) {
const prototype_handle = v8.v8__Object__GetPrototype(new_this_handle).?;
var out: v8.MaybeBool = undefined;
v8.v8__Object__SetPrototype(this.handle, self.local.handle, prototype_handle, &out);
if (comptime IS_DEBUG) {
std.debug.assert(out.has_value and out.value);
}
}
info.getReturnValue().set(this.handle);
}
pub fn method(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = FunctionCallbackInfo{ .handle = handle };
self._method(T, func, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
};
}
fn _method(self: *Caller, comptime T: type, func: anytype, info: FunctionCallbackInfo, comptime opts: CallOpts) !void {
const F = @TypeOf(func);
var args = try self.getArgs(F, 1, info);
const js_this = info.getThis();
@field(args, "0") = try TaggedOpaque.fromJS(*T, js_this);
const res = @call(.auto, func, args);
const mapped = try self.local.zigValueToJs(res, opts);
const return_value = info.getReturnValue();
return_value.set(mapped);
}
pub fn function(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = FunctionCallbackInfo{ .handle = handle };
self._function(func, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
};
}
fn _function(self: *Caller, func: anytype, info: FunctionCallbackInfo, comptime opts: CallOpts) !void {
const F = @TypeOf(func);
const args = try self.getArgs(F, 0, info);
const res = @call(.auto, func, args);
info.getReturnValue().set(try self.local.zigValueToJs(res, opts));
}
pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return self._getIndex(T, func, idx, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
// not intercepted
return 0;
};
}
fn _getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args = try self.getArgs(F, 2, info);
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = idx;
const ret = @call(.auto, func, args);
return self.handleIndexedReturn(T, F, true, ret, info, opts);
}
pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return self._getNamedIndex(T, func, name, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
// not intercepted
return 0;
};
}
fn _getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args = try self.getArgs(F, 2, info);
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try self.nameToString(@TypeOf(args.@"1"), name);
const ret = @call(.auto, func, args);
return self.handleIndexedReturn(T, F, true, ret, info, opts);
}
pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return self._setNamedIndex(T, func, name, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
// not intercepted
return 0;
};
}
fn _setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try self.nameToString(@TypeOf(args.@"1"), name);
@field(args, "2") = try self.local.jsValueToZig(@TypeOf(@field(args, "2")), js_value);
if (@typeInfo(F).@"fn".params.len == 4) {
@field(args, "3") = self.local.ctx.page;
}
const ret = @call(.auto, func, args);
return self.handleIndexedReturn(T, F, false, ret, info, opts);
}
pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return self._deleteNamedIndex(T, func, name, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
return 0;
};
}
fn _deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try self.nameToString(@TypeOf(args.@"1"), name);
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = self.local.ctx.page;
}
const ret = @call(.auto, func, args);
return self.handleIndexedReturn(T, F, false, ret, info, opts);
}
fn handleIndexedReturn(self: *Caller, comptime T: type, comptime F: type, comptime getter: bool, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
// need to unwrap this error immediately for when opts.null_as_undefined == true
// and we need to compare it to null;
const non_error_ret = switch (@typeInfo(@TypeOf(ret))) {
.error_union => |eu| blk: {
break :blk ret catch |err| {
// We can't compare err == error.NotHandled if error.NotHandled
// isn't part of the possible error set. So we first need to check
// if error.NotHandled is part of the error set.
if (isInErrorSet(error.NotHandled, eu.error_set)) {
if (err == error.NotHandled) {
// not intercepted
return 0;
}
}
self.handleError(T, F, err, info, opts);
// not intercepted
return 0;
};
},
else => ret,
};
if (comptime getter) {
info.getReturnValue().set(try self.local.zigValueToJs(non_error_ret, opts));
}
// intercepted
return 1;
}
fn isInErrorSet(err: anyerror, comptime T: type) bool {
inline for (@typeInfo(T).error_set.?) |e| {
if (err == @field(anyerror, e.name)) return true;
}
return false;
}
fn nameToString(self: *const Caller, comptime T: type, name: *const v8.Name) !T {
const handle = @as(*const v8.String, @ptrCast(name));
if (T == string.String) {
return js.String.toSSO(.{ .local = &self.local, .handle = handle }, false);
}
if (T == string.Global) {
return js.String.toSSO(.{ .local = &self.local, .handle = handle }, true);
}
return try js.String.toSlice(.{ .local = &self.local, .handle = handle });
}
fn handleError(self: *Caller, comptime T: type, comptime F: type, err: anyerror, info: anytype, comptime opts: CallOpts) void {
const isolate = self.local.isolate;
if (comptime @import("builtin").mode == .Debug and @TypeOf(info) == FunctionCallbackInfo) {
if (log.enabled(.js, .warn)) {
self.logFunctionCallError(@typeName(T), @typeName(F), err, info);
}
}
const js_err: *const v8.Value = switch (err) {
error.TryCatchRethrow => return,
error.InvalidArgument => isolate.createTypeError("invalid argument"),
error.OutOfMemory => isolate.createError("out of memory"),
error.IllegalConstructor => isolate.createError("Illegal Contructor"),
else => blk: {
if (comptime opts.dom_exception) {
const DOMException = @import("../webapi/DOMException.zig");
if (DOMException.fromError(err)) |ex| {
const value = self.local.zigValueToJs(ex, .{}) catch break :blk isolate.createError("internal error");
break :blk value.handle;
}
}
break :blk isolate.createError(@errorName(err));
},
};
const js_exception = isolate.throwException(js_err);
info.getReturnValue().setValueHandle(js_exception);
}
// If we call a method in javascript: cat.lives('nine');
//
// Then we'd expect a Zig function with 2 parameters: a self and the string.
// In this case, offset == 1. Offset is always 1 for setters or methods.
//
// Offset is always 0 for constructors.
//
// For constructors, setters and methods, we can further increase offset + 1
// if the first parameter is an instance of Page.
//
// Finally, if the JS function is called with _more_ parameters and
// the last parameter in Zig is an array, we'll try to slurp the additional
// parameters into the array.
fn getArgs(self: *const Caller, comptime F: type, comptime offset: usize, info: anytype) !ParameterTypes(F) {
const local = &self.local;
var args: ParameterTypes(F) = undefined;
const params = @typeInfo(F).@"fn".params[offset..];
// Except for the constructor, the first parameter is always `self`
// This isn't something we'll bind from JS, so skip it.
const params_to_map = blk: {
if (params.len == 0) {
return args;
}
// If the last parameter is the Page, set it, and exclude it
// from our params slice, because we don't want to bind it to
// a JS argument
if (comptime isPage(params[params.len - 1].type.?)) {
@field(args, tupleFieldName(params.len - 1 + offset)) = local.ctx.page;
break :blk params[0 .. params.len - 1];
}
// we have neither a Page nor a JsObject. All params must be
// bound to a JavaScript value.
break :blk params;
};
if (params_to_map.len == 0) {
return args;
}
const js_parameter_count = info.length();
const last_js_parameter = params_to_map.len - 1;
var is_variadic = false;
{
// This is going to get complicated. If the last Zig parameter
// is a slice AND the corresponding javascript parameter is
// NOT an an array, then we'll treat it as a variadic.
const last_parameter_type = params_to_map[params_to_map.len - 1].type.?;
const last_parameter_type_info = @typeInfo(last_parameter_type);
if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) {
const slice_type = last_parameter_type_info.pointer.child;
const corresponding_js_value = info.getArg(@intCast(last_js_parameter), local);
if (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8) {
is_variadic = true;
if (js_parameter_count == 0) {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
} else if (js_parameter_count >= params_to_map.len) {
const arr = try local.call_arena.alloc(last_parameter_type_info.pointer.child, js_parameter_count - params_to_map.len + 1);
for (arr, last_js_parameter..) |*a, i| {
a.* = try local.jsValueToZig(slice_type, info.getArg(@intCast(i), local));
}
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = arr;
} else {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
}
}
}
}
inline for (params_to_map, 0..) |param, i| {
const field_index = comptime i + offset;
if (comptime i == params_to_map.len - 1) {
if (is_variadic) {
break;
}
}
if (comptime isPage(param.type.?)) {
@compileError("Page must be the last parameter (or 2nd last if there's a JsThis): " ++ @typeName(F));
} else if (i >= js_parameter_count) {
if (@typeInfo(param.type.?) != .optional) {
return error.InvalidArgument;
}
@field(args, tupleFieldName(field_index)) = null;
} else {
const js_val = info.getArg(@intCast(i), local);
@field(args, tupleFieldName(field_index)) = local.jsValueToZig(param.type.?, js_val) catch {
return error.InvalidArgument;
};
}
}
return args;
}
// This is extracted to speed up compilation. When left inlined in handleError,
// this can add as much as 10 seconds of compilation time.
fn logFunctionCallError(self: *Caller, type_name: []const u8, func: []const u8, err: anyerror, info: FunctionCallbackInfo) void {
const args_dump = self.serializeFunctionArgs(info) catch "failed to serialize args";
log.info(.js, "function call error", .{
.type = type_name,
.func = func,
.err = err,
.args = args_dump,
.stack = self.local.stackTrace() catch |err1| @errorName(err1),
});
}
fn serializeFunctionArgs(self: *Caller, info: FunctionCallbackInfo) ![]const u8 {
const local = &self.local;
var buf = std.Io.Writer.Allocating.init(local.call_arena);
const separator = log.separator();
for (0..info.length()) |i| {
try buf.writer.print("{s}{d} - ", .{ separator, i + 1 });
const js_value = info.getArg(@intCast(i), local);
try local.debugValue(js_value, &buf.writer);
}
return buf.written();
}
// Takes a function, and returns a tuple for its argument. Used when we
// @call a function
fn ParameterTypes(comptime F: type) type {
const params = @typeInfo(F).@"fn".params;
var fields: [params.len]std.builtin.Type.StructField = undefined;
inline for (params, 0..) |param, i| {
fields[i] = .{
.name = tupleFieldName(i),
.type = param.type.?,
.default_value_ptr = null,
.is_comptime = false,
.alignment = @alignOf(param.type.?),
};
}
return @Type(.{ .@"struct" = .{
.layout = .auto,
.decls = &.{},
.fields = &fields,
.is_tuple = true,
} });
}
fn tupleFieldName(comptime i: usize) [:0]const u8 {
return switch (i) {
0 => "0",
1 => "1",
2 => "2",
3 => "3",
4 => "4",
5 => "5",
6 => "6",
7 => "7",
8 => "8",
9 => "9",
else => std.fmt.comptimePrint("{d}", .{i}),
};
}
fn isPage(comptime T: type) bool {
return T == *Page or T == *const Page;
}
// These wrap the raw v8 C API to provide a cleaner interface.
pub const FunctionCallbackInfo = struct {
handle: *const v8.FunctionCallbackInfo,
pub fn length(self: FunctionCallbackInfo) u32 {
return @intCast(v8.v8__FunctionCallbackInfo__Length(self.handle));
}
pub fn getArg(self: FunctionCallbackInfo, index: u32, local: *const js.Local) js.Value {
return .{ .local = local, .handle = v8.v8__FunctionCallbackInfo__INDEX(self.handle, @intCast(index)).? };
}
pub fn getThis(self: FunctionCallbackInfo) *const v8.Object {
return v8.v8__FunctionCallbackInfo__This(self.handle).?;
}
pub fn getReturnValue(self: FunctionCallbackInfo) ReturnValue {
var rv: v8.ReturnValue = undefined;
v8.v8__FunctionCallbackInfo__GetReturnValue(self.handle, &rv);
return .{ .handle = rv };
}
fn isConstructCall(self: FunctionCallbackInfo) bool {
return v8.v8__FunctionCallbackInfo__IsConstructCall(self.handle);
}
};
pub const PropertyCallbackInfo = struct {
handle: *const v8.PropertyCallbackInfo,
pub fn getThis(self: PropertyCallbackInfo) *const v8.Object {
return v8.v8__PropertyCallbackInfo__This(self.handle).?;
}
pub fn getReturnValue(self: PropertyCallbackInfo) ReturnValue {
var rv: v8.ReturnValue = undefined;
v8.v8__PropertyCallbackInfo__GetReturnValue(self.handle, &rv);
return .{ .handle = rv };
}
};
const ReturnValue = struct {
handle: v8.ReturnValue,
pub fn set(self: ReturnValue, value: anytype) void {
const T = @TypeOf(value);
if (T == *const v8.Object) {
self.setValueHandle(@ptrCast(value));
} else if (T == *const v8.Value) {
self.setValueHandle(value);
} else if (T == js.Value) {
self.setValueHandle(value.handle);
} else {
@compileError("Unsupported type for ReturnValue.set: " ++ @typeName(T));
}
}
pub fn setValueHandle(self: ReturnValue, handle: *const v8.Value) void {
v8.v8__ReturnValue__Set(self.handle, handle);
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -18,20 +18,26 @@
const std = @import("std");
const js = @import("js.zig");
const builtin = @import("builtin");
const v8 = js.v8;
const App = @import("../../App.zig");
const log = @import("../../log.zig");
const bridge = @import("bridge.zig");
const Context = @import("Context.zig");
const Isolate = @import("Isolate.zig");
const Platform = @import("Platform.zig");
const Snapshot = @import("Snapshot.zig");
const Inspector = @import("Inspector.zig");
const ExecutionWorld = @import("ExecutionWorld.zig");
const Page = @import("../Page.zig");
const Window = @import("../webapi/Window.zig");
const JsApis = bridge.JsApis;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const IS_DEBUG = builtin.mode == .Debug;
// The Env maps to a V8 isolate, which represents a isolated sandbox for
// executing JavaScript. The Env is where we'll define our V8 <-> Zig bindings,
@@ -41,13 +47,15 @@ const ArenaAllocator = std.heap.ArenaAllocator;
// The `types` parameter is a tuple of Zig structures we want to bind to V8.
const Env = @This();
allocator: Allocator,
app: *App,
platform: *const Platform,
// the global isolate
isolate: js.Isolate,
contexts: std.ArrayList(*js.Context),
// just kept around because we need to free it on deinit
isolate_params: *v8.CreateParams,
@@ -59,7 +67,20 @@ eternal_function_templates: []v8.Eternal,
// Dynamic slice to avoid circular dependency on JsApis.len at comptime
templates: []*const v8.FunctionTemplate,
pub fn init(allocator: Allocator, platform: *const Platform, snapshot: *Snapshot) !Env {
// Global template created once per isolate and reused across all contexts
global_template: v8.Eternal,
// Inspector associated with the Isolate. Exists when CDP is being used.
inspector: ?*Inspector,
pub const InitOpts = struct {
with_inspector: bool = false,
};
pub fn init(app: *App, opts: InitOpts) !Env {
const allocator = app.allocator;
const snapshot = &app.snapshot;
var params = try allocator.create(v8.CreateParams);
errdefer allocator.destroy(params);
v8.v8__Isolate__CreateParams__CONSTRUCT(params);
@@ -72,17 +93,18 @@ pub fn init(allocator: Allocator, platform: *const Platform, snapshot: *Snapshot
var isolate = js.Isolate.init(params);
errdefer isolate.deinit();
const isolate_handle = isolate.handle;
v8.v8__Isolate__SetHostImportModuleDynamicallyCallback(isolate.handle, Context.dynamicModuleCallback);
v8.v8__Isolate__SetPromiseRejectCallback(isolate.handle, promiseRejectCallback);
v8.v8__Isolate__SetMicrotasksPolicy(isolate.handle, v8.kExplicit);
v8.v8__Isolate__SetFatalErrorHandler(isolate.handle, fatalCallback);
v8.v8__Isolate__SetOOMErrorHandler(isolate.handle, oomCallback);
v8.v8__Isolate__SetHostImportModuleDynamicallyCallback(isolate_handle, Context.dynamicModuleCallback);
v8.v8__Isolate__SetPromiseRejectCallback(isolate_handle, promiseRejectCallback);
v8.v8__Isolate__SetMicrotasksPolicy(isolate_handle, v8.kExplicit);
v8.v8__Isolate__SetFatalErrorHandler(isolate_handle, fatalCallback);
v8.v8__Isolate__SetOOMErrorHandler(isolate_handle, oomCallback);
isolate.enter();
errdefer isolate.exit();
v8.v8__Isolate__SetHostInitializeImportMetaObjectCallback(isolate.handle, Context.metaObjectCallback);
v8.v8__Isolate__SetHostInitializeImportMetaObjectCallback(isolate_handle, Context.metaObjectCallback);
// Allocate arrays dynamically to avoid comptime dependency on JsApis.len
const eternal_function_templates = try allocator.alloc(v8.Eternal, JsApis.len);
@@ -91,6 +113,7 @@ pub fn init(allocator: Allocator, platform: *const Platform, snapshot: *Snapshot
const templates = try allocator.alloc(*const v8.FunctionTemplate, JsApis.len);
errdefer allocator.free(templates);
var global_eternal: v8.Eternal = undefined;
{
var temp_scope: js.HandleScope = undefined;
temp_scope.init(isolate);
@@ -98,67 +121,212 @@ pub fn init(allocator: Allocator, platform: *const Platform, snapshot: *Snapshot
inline for (JsApis, 0..) |JsApi, i| {
JsApi.Meta.class_id = i;
const data = v8.v8__Isolate__GetDataFromSnapshotOnce(isolate.handle, snapshot.data_start + i);
const data = v8.v8__Isolate__GetDataFromSnapshotOnce(isolate_handle, snapshot.data_start + i);
const function_handle: *const v8.FunctionTemplate = @ptrCast(data);
// Make function template eternal
v8.v8__Eternal__New(isolate.handle, @ptrCast(function_handle), &eternal_function_templates[i]);
v8.v8__Eternal__New(isolate_handle, @ptrCast(function_handle), &eternal_function_templates[i]);
// Extract the local handle from the global for easy access
const eternal_ptr = v8.v8__Eternal__Get(&eternal_function_templates[i], isolate.handle);
const eternal_ptr = v8.v8__Eternal__Get(&eternal_function_templates[i], isolate_handle);
templates[i] = @ptrCast(@alignCast(eternal_ptr.?));
}
// Create global template once per isolate
const js_global = v8.v8__FunctionTemplate__New__DEFAULT(isolate_handle);
const window_name = v8.v8__String__NewFromUtf8(isolate_handle, "Window", v8.kNormal, 6);
v8.v8__FunctionTemplate__SetClassName(js_global, window_name);
// Find Window in JsApis by name (avoids circular import)
const window_index = comptime bridge.JsApiLookup.getId(Window.JsApi);
v8.v8__FunctionTemplate__Inherit(js_global, templates[window_index]);
const global_template_local = v8.v8__FunctionTemplate__InstanceTemplate(js_global).?;
v8.v8__ObjectTemplate__SetNamedHandler(global_template_local, &.{
.getter = bridge.unknownWindowPropertyCallback,
.setter = null,
.query = null,
.deleter = null,
.enumerator = null,
.definer = null,
.descriptor = null,
.data = null,
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
});
v8.v8__Eternal__New(isolate_handle, @ptrCast(global_template_local), &global_eternal);
}
var inspector: ?*js.Inspector = null;
if (opts.with_inspector) {
inspector = try Inspector.init(allocator, isolate_handle);
}
return .{
.app = app,
.context_id = 0,
.contexts = .empty,
.isolate = isolate,
.platform = platform,
.allocator = allocator,
.platform = &app.platform,
.templates = templates,
.isolate_params = params,
.inspector = inspector,
.eternal_function_templates = eternal_function_templates,
.global_template = global_eternal,
};
}
pub fn deinit(self: *Env) void {
self.allocator.free(self.templates);
self.allocator.free(self.eternal_function_templates);
if (comptime IS_DEBUG) {
std.debug.assert(self.contexts.items.len == 0);
}
for (self.contexts.items) |ctx| {
ctx.deinit();
}
const allocator = self.app.allocator;
if (self.inspector) |i| {
i.deinit(allocator);
}
self.contexts.deinit(allocator);
allocator.free(self.templates);
allocator.free(self.eternal_function_templates);
self.isolate.exit();
self.isolate.deinit();
v8.v8__ArrayBuffer__Allocator__DELETE(self.isolate_params.array_buffer_allocator.?);
self.allocator.destroy(self.isolate_params);
allocator.destroy(self.isolate_params);
}
pub fn newInspector(self: *Env, arena: Allocator, ctx: anytype) !*Inspector {
const inspector = try arena.create(Inspector);
try Inspector.init(inspector, self.isolate.handle, ctx);
return inspector;
pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context {
const context_arena = try self.app.arena_pool.acquire();
errdefer self.app.arena_pool.release(context_arena);
const isolate = self.isolate;
var hs: js.HandleScope = undefined;
hs.init(isolate);
defer hs.deinit();
// Get the global template that was created once per isolate
const global_template: *const v8.ObjectTemplate = @ptrCast(@alignCast(v8.v8__Eternal__Get(&self.global_template, isolate.handle).?));
const v8_context = v8.v8__Context__New(isolate.handle, global_template, null).?;
// Create the v8::Context and wrap it in a v8::Global
var context_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, v8_context, &context_global);
// our window wrapped in a v8::Global
const global_obj = v8.v8__Context__Global(v8_context).?;
var global_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, global_obj, &global_global);
if (enter) {
v8.v8__Context__Enter(v8_context);
}
errdefer if (enter) {
v8.v8__Context__Exit(v8_context);
};
const context_id = self.context_id;
self.context_id = context_id + 1;
const context = try context_arena.create(Context);
context.* = .{
.env = self,
.page = page,
.id = context_id,
.entered = enter,
.isolate = isolate,
.arena = context_arena,
.handle = context_global,
.templates = self.templates,
.call_arena = page.call_arena,
.script_manager = &page._script_manager,
.scheduler = .init(context_arena),
.finalizer_callback_pool = std.heap.MemoryPool(Context.FinalizerCallback).init(self.app.allocator),
};
try context.identity_map.putNoClobber(context_arena, @intFromPtr(page.window), global_global);
// Store a pointer to our context inside the v8 context so that, given
// a v8 context, we can get our context out
const data = isolate.initBigInt(@intFromPtr(context));
v8.v8__Context__SetEmbedderData(v8_context, 1, @ptrCast(data.handle));
try self.contexts.append(self.app.allocator, context);
return context;
}
pub fn destroyContext(self: *Env, context: *Context) void {
for (self.contexts.items, 0..) |ctx, i| {
if (ctx == context) {
_ = self.contexts.swapRemove(i);
break;
}
} else {
if (comptime IS_DEBUG) {
@panic("Tried to remove unknown context");
}
}
const isolate = self.isolate;
if (self.inspector) |inspector| {
var hs: js.HandleScope = undefined;
hs.init(isolate);
defer hs.deinit();
inspector.contextDestroyed(@ptrCast(v8.v8__Global__Get(&context.handle, isolate.handle)));
}
context.deinit();
isolate.notifyContextDisposed();
}
pub fn runMicrotasks(self: *const Env) void {
self.isolate.performMicrotasksCheckpoint();
}
pub fn runMacrotasks(self: *Env) !?u64 {
var ms_to_next_task: ?u64 = null;
for (self.contexts.items) |ctx| {
if (comptime builtin.is_test == false) {
// I hate this comptime check as much as you do. But we have tests
// which rely on short execution before shutdown. In real world, it's
// underterministic whether a timer will or won't run before the
// page shutsdown. But for tests, we need to run them to their end.
if (ctx.scheduler.hasReadyTasks() == false) {
continue;
}
}
var hs: js.HandleScope = undefined;
const entered = ctx.enter(&hs);
defer entered.exit();
const ms = (try ctx.scheduler.run()) orelse continue;
if (ms_to_next_task == null or ms < ms_to_next_task.?) {
ms_to_next_task = ms;
}
}
return ms_to_next_task;
}
pub fn pumpMessageLoop(self: *const Env) bool {
var hs: v8.HandleScope = undefined;
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle);
defer v8.v8__HandleScope__DESTRUCT(&hs);
return v8.v8__Platform__PumpMessageLoop(self.platform.handle, self.isolate.handle, false);
}
pub fn runIdleTasks(self: *const Env) void {
v8.v8__Platform__RunIdleTasks(self.platform.handle, self.isolate.handle, 1);
}
pub fn newExecutionWorld(self: *Env) !ExecutionWorld {
return .{
.env = self,
.context = null,
.context_arena = ArenaAllocator.init(self.allocator),
};
}
// V8 doesn't immediately free memory associated with
// a Context, it's managed by the garbage collector. We use the
// `lowMemoryNotification` call on the isolate to encourage v8 to free
// any contexts which have been freed.
// This GC is very aggressive. Use memoryPressureNotification for less
// aggressive GC passes.
pub fn lowMemoryNotification(self: *Env) void {
var handle_scope: js.HandleScope = undefined;
handle_scope.init(self.isolate);
@@ -166,6 +334,21 @@ pub fn lowMemoryNotification(self: *Env) void {
self.isolate.lowMemoryNotification();
}
// V8 doesn't immediately free memory associated with
// a Context, it's managed by the garbage collector. We use the
// `memoryPressureNotification` call on the isolate to encourage v8 to free
// any contexts which have been freed.
// The level indicates the aggressivity of the GC required:
// moderate speeds up incremental GC
// critical runs one full GC
// For a more aggressive GC, use lowMemoryNotification.
pub fn memoryPressureNotification(self: *Env, level: Isolate.MemoryPressureLevel) void {
var handle_scope: js.HandleScope = undefined;
handle_scope.init(self.isolate);
defer handle_scope.deinit();
self.isolate.memoryPressureNotification(level);
}
pub fn dumpMemoryStats(self: *Env) void {
const stats = self.isolate.getHeapStatistics();
std.debug.print(
@@ -189,21 +372,24 @@ pub fn dumpMemoryStats(self: *Env) void {
fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) void {
const promise_handle = v8.v8__PromiseRejectMessage__GetPromise(&message_handle).?;
const isolate_handle = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?;
const js_isolate = js.Isolate{ .handle = isolate_handle };
const context = Context.fromIsolate(js_isolate);
const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?;
const js_isolate = js.Isolate{ .handle = v8_isolate };
const ctx = Context.fromIsolate(js_isolate);
const value =
if (v8.v8__PromiseRejectMessage__GetValue(&message_handle)) |v8_value|
context.valueToString(.{ .ctx = context, .handle = v8_value }, .{}) catch |err| @errorName(err)
else
"no value";
const local = js.Local{
.ctx = ctx,
.isolate = js_isolate,
.handle = v8.v8__Isolate__GetCurrentContext(v8_isolate).?,
.call_arena = ctx.call_arena,
};
log.debug(.js, "unhandled rejection", .{
.value = value,
.stack = context.stackTrace() catch |err| @errorName(err) orelse "???",
.note = "This should be updated to call window.unhandledrejection",
});
const page = ctx.page;
page.window.unhandledPromiseRejection(.{
.local = &local,
.handle = &message_handle,
}, page) catch |err| {
log.warn(.browser, "unhandled rejection handler", .{ .err = err });
};
}
fn fatalCallback(c_location: [*c]const u8, c_message: [*c]const u8) callconv(.c) void {

View File

@@ -1,163 +0,0 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const log = @import("../../log.zig");
const Page = @import("../Page.zig");
const js = @import("js.zig");
const v8 = js.v8;
const Env = @import("Env.zig");
const bridge = @import("bridge.zig");
const Context = @import("Context.zig");
const ArenaAllocator = std.heap.ArenaAllocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const CONTEXT_ARENA_RETAIN = 1024 * 64;
// ExecutionWorld closely models a JS World.
// https://chromium.googlesource.com/chromium/src/+/master/third_party/blink/renderer/bindings/core/v8/V8BindingDesign.md#World
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld
const ExecutionWorld = @This();
env: *Env,
// Arena whose lifetime is for a single page load. Where
// the call_arena lives for a single function call, the context_arena
// lives for the lifetime of the entire page. The allocator will be
// owned by the Context, but the arena itself is owned by the ExecutionWorld
// so that we can re-use it from context to context.
context_arena: ArenaAllocator,
// Currently a context maps to a Browser's Page. Here though, it's only a
// mechanism to organization page-specific memory. The ExecutionWorld
// does all the work, but having all page-specific data structures
// grouped together helps keep things clean.
context: ?Context = null,
persisted_context: ?js.Global(Context) = null,
// no init, must be initialized via env.newExecutionWorld()
pub fn deinit(self: *ExecutionWorld) void {
if (self.context != null) {
self.removeContext();
}
self.context_arena.deinit();
}
// Only the top Context in the Main ExecutionWorld should hold a handle_scope.
// A js.HandleScope is like an arena. Once created, any "Local" that
// v8 creates will be released (or at least, releasable by the v8 GC)
// when the handle_scope is freed.
// We also maintain our own "context_arena" which allows us to have
// all page related memory easily managed.
pub fn createContext(self: *ExecutionWorld, page: *Page, enter: bool) !*Context {
lp.assert(self.context == null, "ExecptionWorld.createContext has context", .{});
const env = self.env;
const isolate = env.isolate;
const arena = self.context_arena.allocator();
const persisted_context: js.Global(Context) = blk: {
var temp_scope: js.HandleScope = undefined;
temp_scope.init(isolate);
defer temp_scope.deinit();
// Getting this into the snapshot is tricky (anything involving the
// global is tricky). Easier to do here
const global_template = @import("Snapshot.zig").createGlobalTemplate(isolate.handle, env.templates);
v8.v8__ObjectTemplate__SetNamedHandler(global_template, &.{
.getter = bridge.unknownPropertyCallback,
.setter = null,
.query = null,
.deleter = null,
.enumerator = null,
.definer = null,
.descriptor = null,
.data = null,
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
});
const context_handle = v8.v8__Context__New(isolate.handle, global_template, null).?;
break :blk js.Global(Context).init(isolate.handle, context_handle);
};
// For a Page we only create one HandleScope, it is stored in the main World (enter==true). A page can have multple contexts, 1 for each World.
// The main Context that enters and holds the HandleScope should therefore always be created first. Following other worlds for this page
// like isolated Worlds, will thereby place their objects on the main page's HandleScope. Note: In the furure the number of context will multiply multiple frames support
const v8_context = persisted_context.local();
var handle_scope: ?js.HandleScope = null;
if (enter) {
handle_scope = @as(js.HandleScope, undefined);
handle_scope.?.init(isolate);
v8.v8__Context__Enter(v8_context);
}
errdefer if (enter) {
v8.v8__Context__Exit(v8_context);
handle_scope.?.deinit();
};
const context_id = env.context_id;
env.context_id = context_id + 1;
self.context = Context{
.page = page,
.id = context_id,
.isolate = isolate,
.handle = v8_context,
.templates = env.templates,
.handle_scope = handle_scope,
.script_manager = &page._script_manager,
.call_arena = page.call_arena,
.arena = arena,
};
self.persisted_context = persisted_context;
var context = &self.context.?;
// Store a pointer to our context inside the v8 context so that, given
// a v8 context, we can get our context out
const data = isolate.initBigInt(@intFromPtr(context));
v8.v8__Context__SetEmbedderData(context.handle, 1, @ptrCast(data.handle));
try context.setupGlobal();
return context;
}
pub fn removeContext(self: *ExecutionWorld) void {
var context = &(self.context orelse return);
context.deinit();
self.context = null;
self.persisted_context.?.deinit();
self.persisted_context = null;
self.env.isolate.notifyContextDisposed();
_ = self.context_arena.reset(.{ .retain_with_limit = CONTEXT_ARENA_RETAIN });
}
pub fn terminateExecution(self: *const ExecutionWorld) void {
self.env.isolate.terminateExecution();
}
pub fn resumeExecution(self: *const ExecutionWorld) void {
self.env.isolate.cancelTerminateExecution();
}

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -20,11 +20,11 @@ const std = @import("std");
const js = @import("js.zig");
const v8 = js.v8;
const Allocator = std.mem.Allocator;
const log = @import("../../log.zig");
const Function = @This();
ctx: *js.Context,
local: *const js.Local,
this: ?*const v8.Object = null,
handle: *const v8.Function,
@@ -34,60 +34,77 @@ pub const Result = struct {
};
pub fn withThis(self: *const Function, value: anytype) !Function {
const local = self.local;
const this_obj = if (@TypeOf(value) == js.Object)
value.handle
else
(try self.ctx.zigValueToJs(value, .{})).handle;
(try local.zigValueToJs(value, .{})).handle;
return .{
.ctx = self.ctx,
.local = local,
.this = this_obj,
.handle = self.handle,
};
}
pub fn newInstance(self: *const Function, caught: *js.TryCatch.Caught) !js.Object {
const ctx = self.ctx;
const local = self.local;
var try_catch: js.TryCatch = undefined;
try_catch.init(ctx);
try_catch.init(local);
defer try_catch.deinit();
// This creates a new instance using this Function as a constructor.
// const c_args = @as(?[*]const ?*c.Value, @ptrCast(&.{}));
const handle = v8.v8__Function__NewInstance(self.handle, ctx.handle, 0, null) orelse {
caught.* = try_catch.caughtOrError(ctx.call_arena, error.Unknown);
const handle = v8.v8__Function__NewInstance(self.handle, local.handle, 0, null) orelse {
caught.* = try_catch.caughtOrError(local.call_arena, error.Unknown);
return error.JsConstructorFailed;
};
return .{
.ctx = ctx,
.local = local,
.handle = handle,
};
}
pub fn call(self: *const Function, comptime T: type, args: anytype) !T {
return self.callWithThis(T, self.getThis(), args);
var caught: js.TryCatch.Caught = undefined;
return self._tryCallWithThis(T, self.getThis(), args, &caught, .{}) catch |err| {
log.warn(.js, "call caught", .{ .err = err, .caught = caught });
return err;
};
}
pub fn tryCall(self: *const Function, comptime T: type, args: anytype, caught: *js.TryCatch.Caught) !T {
return self.tryCallWithThis(T, self.getThis(), args, caught);
}
pub fn tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype, caught: *js.TryCatch.Caught) !T {
var try_catch: js.TryCatch = undefined;
try_catch.init(self.ctx);
defer try_catch.deinit();
return self.callWithThis(T, this, args) catch |err| {
caught.* = try_catch.caughtOrError(self.ctx.call_arena, err);
pub fn callRethrow(self: *const Function, comptime T: type, args: anytype) !T {
var caught: js.TryCatch.Caught = undefined;
return self._tryCallWithThis(T, self.getThis(), args, &caught, .{ .rethrow = true }) catch |err| {
log.warn(.js, "call caught", .{ .err = err, .caught = caught });
return err;
};
}
pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype) !T {
const ctx = self.ctx;
var caught: js.TryCatch.Caught = undefined;
return self._tryCallWithThis(T, this, args, &caught, .{}) catch |err| {
log.warn(.js, "callWithThis caught", .{ .err = err, .caught = caught });
return err;
};
}
pub fn tryCall(self: *const Function, comptime T: type, args: anytype, caught: *js.TryCatch.Caught) !T {
return self._tryCallWithThis(T, self.getThis(), args, caught, .{});
}
pub fn tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype, caught: *js.TryCatch.Caught) !T {
return self._tryCallWithThis(T, this, args, caught, .{});
}
const CallOpts = struct {
rethrow: bool = false,
};
fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype, caught: *js.TryCatch.Caught, comptime opts: CallOpts) !T {
caught.* = .{};
const local = self.local;
// When we're calling a function from within JavaScript itself, this isn't
// necessary. We're within a Caller instantiation, which will already have
@@ -98,6 +115,7 @@ pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args
// need to increase the call_depth so that the call_arena remains valid for
// the duration of the function call. If we don't do this, the call_arena
// will be reset after each statement of the function which executes Zig code.
const ctx = local.ctx;
const call_depth = ctx.call_depth;
ctx.call_depth = call_depth + 1;
defer ctx.call_depth = call_depth;
@@ -106,7 +124,7 @@ pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args
if (@TypeOf(this) == js.Object) {
break :blk this;
}
break :blk try ctx.zigValueToJs(this, .{});
break :blk try local.zigValueToJs(this, .{});
};
const aargs = if (comptime @typeInfo(@TypeOf(args)) == .null) struct {}{} else args;
@@ -116,15 +134,15 @@ pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args
const fields = s.fields;
var js_args: [fields.len]*const v8.Value = undefined;
inline for (fields, 0..) |f, i| {
js_args[i] = (try ctx.zigValueToJs(@field(aargs, f.name), .{})).handle;
js_args[i] = (try local.zigValueToJs(@field(aargs, f.name), .{})).handle;
}
const cargs: [fields.len]*const v8.Value = js_args;
break :blk &cargs;
},
.pointer => blk: {
var values = try ctx.call_arena.alloc(*const v8.Value, args.len);
var values = try local.call_arena.alloc(*const v8.Value, args.len);
for (args, 0..) |a, i| {
values[i] = (try ctx.zigValueToJs(a, .{})).handle;
values[i] = (try local.zigValueToJs(a, .{})).handle;
}
break :blk values;
},
@@ -132,54 +150,75 @@ pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args
};
const c_args = @as(?[*]const ?*v8.Value, @ptrCast(js_args.ptr));
const handle = v8.v8__Function__Call(self.handle, ctx.handle, js_this.handle, @as(c_int, @intCast(js_args.len)), c_args) orelse {
// std.debug.print("CB ERR: {s}\n", .{self.src() catch "???"});
var try_catch: js.TryCatch = undefined;
try_catch.init(local);
defer try_catch.deinit();
const handle = v8.v8__Function__Call(self.handle, local.handle, js_this.handle, @as(c_int, @intCast(js_args.len)), c_args) orelse {
if ((comptime opts.rethrow) and try_catch.hasCaught()) {
try_catch.rethrow();
return error.TryCatchRethrow;
}
caught.* = try_catch.caughtOrError(local.call_arena, error.JSExecCallback);
return error.JSExecCallback;
};
if (@typeInfo(T) == .void) {
return {};
}
return ctx.jsValueToZig(T, .{ .ctx = ctx, .handle = handle });
return local.jsValueToZig(T, .{ .local = local, .handle = handle });
}
fn getThis(self: *const Function) js.Object {
const handle = if (self.this) |t| t else v8.v8__Context__Global(self.ctx.handle).?;
const handle = if (self.this) |t| t else v8.v8__Context__Global(self.local.handle).?;
return .{
.ctx = self.ctx,
.local = self.local,
.handle = handle,
};
}
pub fn src(self: *const Function) ![]const u8 {
return self.context.valueToString(.{ .handle = @ptrCast(self.handle) }, .{});
return self.local.valueToString(.{ .local = self.local, .handle = @ptrCast(self.handle) }, .{});
}
pub fn getPropertyValue(self: *const Function, name: []const u8) !?js.Value {
const ctx = self.ctx;
const key = ctx.isolate.initStringHandle(name);
const handle = v8.v8__Object__Get(self.handle, ctx.handle, key) orelse {
const local = self.local;
const key = local.isolate.initStringHandle(name);
const handle = v8.v8__Object__Get(self.handle, self.local.handle, key) orelse {
return error.JsException;
};
return .{
.ctx = ctx,
.local = local,
.handle = handle,
};
}
pub fn persist(self: *const Function) !Global {
var ctx = self.ctx;
return self._persist(true);
}
pub fn temp(self: *const Function) !Temp {
return self._persist(false);
}
fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Global else Temp) {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.global_functions.append(ctx.arena, global);
} else {
try ctx.global_functions_temp.put(ctx.arena, global.data_ptr, global);
}
return .{ .handle = global };
}
try ctx.global_functions.append(ctx.arena, global);
return .{
.handle = global,
.ctx = ctx,
};
pub fn tempWithThis(self: *const Function, value: anytype) !Temp {
const with_this = try self.withThis(value);
return with_this.temp();
}
pub fn persistWithThis(self: *const Function, value: anytype) !Global {
@@ -187,22 +226,31 @@ pub fn persistWithThis(self: *const Function, value: anytype) !Global {
return with_this.persist();
}
pub const Global = struct {
handle: v8.Global,
ctx: *js.Context,
pub const Temp = G(0);
pub const Global = G(1);
pub fn deinit(self: *Global) void {
v8.v8__Global__Reset(&self.handle);
}
fn G(comptime discriminator: u8) type {
return struct {
handle: v8.Global,
pub fn local(self: *const Global) Function {
return .{
.ctx = self.ctx,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, self.ctx.isolate.handle)),
};
}
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
pub fn isEqual(self: *const Global, other: Function) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
};
const Self = @This();
pub fn deinit(self: *Self) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Self, l: *const js.Local) Function {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
pub fn isEqual(self: *const Self, other: Function) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
};
}

View File

@@ -20,102 +20,79 @@ const std = @import("std");
const js = @import("js.zig");
const v8 = js.v8;
const Context = @import("Context.zig");
const TaggedOpaque = @import("TaggedOpaque.zig");
const Allocator = std.mem.Allocator;
const RndGen = std.Random.DefaultPrng;
const CONTEXT_GROUP_ID = 1;
const CLIENT_TRUST_LEVEL = 1;
const IS_DEBUG = @import("builtin").mode == .Debug;
// Inspector exists for the lifetime of the Isolate/Env. 1 Isolate = 1 Inspector.
// It combines the v8.Inspector and the v8.InspectorClientImpl. The v8.InspectorClientImpl
// is our own implementation that fulfills the InspectorClient API, i.e. it's the
// mechanism v8 provides to let us tweak how the inspector works. For example, it
// Below, you'll find a few pub export fn v8_inspector__Client__IMPL__XYZ functions
// which is our implementation of what the v8::Inspector requires of our Client
// (not much at all)
const Inspector = @This();
handle: *v8.Inspector,
unique_id: i64,
isolate: *v8.Isolate,
client: Client,
channel: Channel,
session: Session,
rnd: RndGen = RndGen.init(0),
default_context: ?*const v8.Context = null,
handle: *v8.Inspector,
client: *v8.InspectorClientImpl,
default_context: ?v8.Global,
session: ?Session,
// We expect allocator to be an arena
// Note: This initializes the pre-allocated inspector in-place
pub fn init(self: *Inspector, isolate: *v8.Isolate, ctx: anytype) !void {
const ContextT = @TypeOf(ctx);
pub fn init(allocator: Allocator, isolate: *v8.Isolate) !*Inspector {
const self = try allocator.create(Inspector);
errdefer allocator.destroy(self);
const Container = switch (@typeInfo(ContextT)) {
.@"struct" => ContextT,
.pointer => |ptr| ptr.child,
.void => NoopInspector,
else => @compileError("invalid context type"),
};
// If necessary, turn a void context into something we can safely ptrCast
const safe_context: *anyopaque = if (ContextT == void) @ptrCast(@constCast(&{})) else ctx;
// Initialize the fields that callbacks need first
self.* = .{
.handle = undefined,
.unique_id = 1,
.session = null,
.isolate = isolate,
.client = undefined,
.channel = undefined,
.rnd = RndGen.init(0),
.handle = undefined,
.default_context = null,
.session = undefined,
};
// Create client and set inspector data BEFORE creating the inspector
// because V8 will call generateUniqueId during inspector creation
const client = Client.init();
self.client = client;
client.setInspector(self);
self.client = v8.v8_inspector__Client__IMPL__CREATE();
errdefer v8.v8_inspector__Client__IMPL__DELETE(self.client);
v8.v8_inspector__Client__IMPL__SET_DATA(self.client, self);
// Now create the inspector - generateUniqueId will work because data is set
const handle = v8.v8_inspector__Inspector__Create(isolate, client.handle).?;
self.handle = handle;
self.handle = v8.v8_inspector__Inspector__Create(isolate, self.client).?;
errdefer v8.v8_inspector__Inspector__DELETE(self.handle);
// Create the channel
const channel = Channel.init(
safe_context,
Container.onInspectorResponse,
Container.onInspectorEvent,
Container.onRunMessageLoopOnPause,
Container.onQuitMessageLoopOnPause,
isolate,
);
self.channel = channel;
channel.setInspector(self);
// Create the session
const session_handle = v8.v8_inspector__Inspector__Connect(
handle,
CONTEXT_GROUP_ID,
channel.handle,
CLIENT_TRUST_LEVEL,
).?;
self.session = .{ .handle = session_handle };
return self;
}
pub fn deinit(self: *const Inspector) void {
var temp_scope: v8.HandleScope = undefined;
v8.v8__HandleScope__CONSTRUCT(&temp_scope, self.isolate);
defer v8.v8__HandleScope__DESTRUCT(&temp_scope);
pub fn deinit(self: *const Inspector, allocator: Allocator) void {
var hs: v8.HandleScope = undefined;
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate);
defer v8.v8__HandleScope__DESTRUCT(&hs);
self.session.deinit();
self.client.deinit();
self.channel.deinit();
if (self.session) |*s| {
s.deinit();
}
v8.v8_inspector__Client__IMPL__DELETE(self.client);
v8.v8_inspector__Inspector__DELETE(self.handle);
allocator.destroy(self);
}
pub fn send(self: *const Inspector, msg: []const u8) void {
// Can't assume the main Context exists (with its HandleScope)
// available when doing this. Pages (and thus the HandleScope)
// comes and goes, but CDP can keep sending messages.
const isolate = self.isolate;
var temp_scope: v8.HandleScope = undefined;
v8.v8__HandleScope__CONSTRUCT(&temp_scope, isolate);
defer v8.v8__HandleScope__DESTRUCT(&temp_scope);
pub fn startSession(self: *Inspector, ctx: anytype) *Session {
if (comptime IS_DEBUG) {
std.debug.assert(self.session == null);
}
self.session.dispatchProtocolMessage(isolate, msg);
self.session = @as(Session, undefined);
Session.init(&self.session.?, self, ctx);
return &self.session.?;
}
pub fn stopSession(self: *Inspector) void {
self.session.?.deinit();
self.session = null;
}
// From CDP docs
@@ -128,7 +105,7 @@ pub fn send(self: *const Inspector, msg: []const u8) void {
// - is_default_context: Whether the execution context is default, should match the auxData
pub fn contextCreated(
self: *Inspector,
context: *const Context,
local: *const js.Local,
name: []const u8,
origin: []const u8,
aux_data: []const u8,
@@ -143,56 +120,24 @@ pub fn contextCreated(
aux_data.ptr,
aux_data.len,
CONTEXT_GROUP_ID,
context.handle,
local.handle,
);
if (is_default_context) {
self.default_context = context.handle;
self.default_context = local.ctx.handle;
}
}
// Retrieves the RemoteObject for a given value.
// The value is loaded through the ExecutionWorld's mapZigInstanceToJs function,
// just like a method return value. Therefore, if we've mapped this
// value before, we'll get the existing js.Global(js.Object) and if not
// we'll create it and track it for cleanup when the context ends.
pub fn getRemoteObject(
self: *const Inspector,
context: *Context,
group: []const u8,
value: anytype,
) !RemoteObject {
const js_value = try context.zigValueToJs(value, .{});
// We do not want to expose this as a parameter for now
const generate_preview = false;
return self.session.wrapObject(
context.isolate.handle,
context.handle,
js_value.handle,
group,
generate_preview,
);
pub fn contextDestroyed(self: *Inspector, context: *const v8.Context) void {
v8.v8_inspector__Inspector__ContextDestroyed(self.handle, context);
}
// Gets a value by object ID regardless of which context it is in.
// Our TaggedAnyOpaque stores the "resolved" ptr value (the most specific _type,
// e.g. we store the ptr to the Div not the EventTarget). But, this is asking for
// the pointer to the Node, so we need to use the same resolution mechanism which
// is used when we're calling a function to turn the Div into a Node, which is
// what Context.typeTaggedAnyOpaque does.
pub fn getNodePtr(self: *const Inspector, allocator: Allocator, object_id: []const u8) !*anyopaque {
const unwrapped = try self.session.unwrapObject(allocator, object_id);
// The values context and groupId are not used here
const js_val = unwrapped.value;
if (!v8.v8__Value__IsObject(js_val)) {
return error.ObjectIdIsNotANode;
}
const Node = @import("../webapi/Node.zig");
// Cast to *const v8.Object for typeTaggedAnyOpaque
return Context.typeTaggedAnyOpaque(*Node, @ptrCast(js_val)) catch {
return error.ObjectIdIsNotANode;
};
pub fn resetContextGroup(self: *const Inspector) void {
var hs: v8.HandleScope = undefined;
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate);
defer v8.v8__HandleScope__DESTRUCT(&hs);
v8.v8_inspector__Inspector__ResetContextGroup(self.handle, CONTEXT_GROUP_ID);
}
pub const RemoteObject = struct {
@@ -241,20 +186,109 @@ pub const RemoteObject = struct {
}
};
const Session = struct {
// Combines a v8::InspectorSession and a v8::InspectorChannelImpl. The
// InspectorSession is for zig -> v8 (sending messages to the inspector). The
// Channel is for v8 -> zig, getting events from the Inspector (that we'll pass
// back ot some opaque context, i.e the CDP BrowserContext).
// The channel callbacks are defined below, as:
// pub export fn v8_inspector__Channel__IMPL__XYZ
pub const Session = struct {
inspector: *Inspector,
handle: *v8.InspectorSession,
channel: *v8.InspectorChannelImpl,
fn deinit(self: Session) void {
v8.v8_inspector__Session__DELETE(self.handle);
// callbacks
ctx: *anyopaque,
onNotif: *const fn (ctx: *anyopaque, msg: []const u8) void,
onResp: *const fn (ctx: *anyopaque, call_id: u32, msg: []const u8) void,
fn init(self: *Session, inspector: *Inspector, ctx: anytype) void {
const Container = @typeInfo(@TypeOf(ctx)).pointer.child;
const channel = v8.v8_inspector__Channel__IMPL__CREATE(inspector.isolate);
const handle = v8.v8_inspector__Inspector__Connect(
inspector.handle,
CONTEXT_GROUP_ID,
channel,
CLIENT_TRUST_LEVEL,
).?;
v8.v8_inspector__Channel__IMPL__SET_DATA(channel, self);
self.* = .{
.ctx = ctx,
.handle = handle,
.channel = channel,
.inspector = inspector,
.onResp = Container.onInspectorResponse,
.onNotif = Container.onInspectorEvent,
};
}
fn dispatchProtocolMessage(self: Session, isolate: *v8.Isolate, msg: []const u8) void {
fn deinit(self: *const Session) void {
v8.v8_inspector__Session__DELETE(self.handle);
v8.v8_inspector__Channel__IMPL__DELETE(self.channel);
}
pub fn send(self: *const Session, msg: []const u8) void {
const isolate = self.inspector.isolate;
var hs: v8.HandleScope = undefined;
v8.v8__HandleScope__CONSTRUCT(&hs, isolate);
defer v8.v8__HandleScope__DESTRUCT(&hs);
v8.v8_inspector__Session__dispatchProtocolMessage(
self.handle,
isolate,
msg.ptr,
msg.len,
);
v8.v8__Isolate__PerformMicrotaskCheckpoint(isolate);
}
// Gets a value by object ID regardless of which context it is in.
// Our TaggedOpaque stores the "resolved" ptr value (the most specific _type,
// e.g. we store the ptr to the Div not the EventTarget). But, this is asking for
// the pointer to the Node, so we need to use the same resolution mechanism which
// is used when we're calling a function to turn the Div into a Node, which is
// what TaggedOpaque.fromJS does.
pub fn getNodePtr(self: *const Session, allocator: Allocator, object_id: []const u8, local: *js.Local) !*anyopaque {
// just to indicate that the caller is responsible for ensuring there's a local environment
_ = local;
const unwrapped = try self.unwrapObject(allocator, object_id);
// The values context and groupId are not used here
const js_val = unwrapped.value;
if (!v8.v8__Value__IsObject(js_val)) {
return error.ObjectIdIsNotANode;
}
const Node = @import("../webapi/Node.zig");
// Cast to *const v8.Object for typeTaggedAnyOpaque
return TaggedOpaque.fromJS(*Node, @ptrCast(js_val)) catch return error.ObjectIdIsNotANode;
}
// Retrieves the RemoteObject for a given value.
// The value is loaded through the ExecutionWorld's mapZigInstanceToJs function,
// just like a method return value. Therefore, if we've mapped this
// value before, we'll get the existing js.Global(js.Object) and if not
// we'll create it and track it for cleanup when the context ends.
pub fn getRemoteObject(
self: *const Session,
local: *const js.Local,
group: []const u8,
value: anytype,
) !RemoteObject {
const js_val = try local.zigValueToJs(value, .{});
// We do not want to expose this as a parameter for now
const generate_preview = false;
return self.wrapObject(
local.isolate.handle,
local.handle,
js_val.handle,
group,
generate_preview,
);
}
fn wrapObject(
@@ -321,85 +355,7 @@ const UnwrappedObject = struct {
object_group: ?[]const u8,
};
const Channel = struct {
handle: *v8.InspectorChannelImpl,
// callbacks
ctx: *anyopaque,
onNotif: onNotifFn = undefined,
onResp: onRespFn = undefined,
onRunMessageLoopOnPause: onRunMessageLoopOnPauseFn = undefined,
onQuitMessageLoopOnPause: onQuitMessageLoopOnPauseFn = undefined,
pub const onNotifFn = *const fn (ctx: *anyopaque, msg: []const u8) void;
pub const onRespFn = *const fn (ctx: *anyopaque, call_id: u32, msg: []const u8) void;
pub const onRunMessageLoopOnPauseFn = *const fn (ctx: *anyopaque, context_group_id: u32) void;
pub const onQuitMessageLoopOnPauseFn = *const fn (ctx: *anyopaque) void;
fn init(
ctx: *anyopaque,
onResp: onRespFn,
onNotif: onNotifFn,
onRunMessageLoopOnPause: onRunMessageLoopOnPauseFn,
onQuitMessageLoopOnPause: onQuitMessageLoopOnPauseFn,
isolate: *v8.Isolate,
) Channel {
const handle = v8.v8_inspector__Channel__IMPL__CREATE(isolate);
return .{
.handle = handle,
.ctx = ctx,
.onResp = onResp,
.onNotif = onNotif,
.onRunMessageLoopOnPause = onRunMessageLoopOnPause,
.onQuitMessageLoopOnPause = onQuitMessageLoopOnPause,
};
}
fn deinit(self: Channel) void {
v8.v8_inspector__Channel__IMPL__DELETE(self.handle);
}
fn setInspector(self: Channel, inspector: *anyopaque) void {
v8.v8_inspector__Channel__IMPL__SET_DATA(self.handle, inspector);
}
fn resp(self: Channel, call_id: u32, msg: []const u8) void {
self.onResp(self.ctx, call_id, msg);
}
fn notif(self: Channel, msg: []const u8) void {
self.onNotif(self.ctx, msg);
}
};
const Client = struct {
handle: *v8.InspectorClientImpl,
fn init() Client {
return .{ .handle = v8.v8_inspector__Client__IMPL__CREATE() };
}
fn deinit(self: Client) void {
v8.v8_inspector__Client__IMPL__DELETE(self.handle);
}
fn setInspector(self: Client, inspector: *anyopaque) void {
v8.v8_inspector__Client__IMPL__SET_DATA(self.handle, inspector);
}
};
const NoopInspector = struct {
pub fn onInspectorResponse(_: *anyopaque, _: u32, _: []const u8) void {}
pub fn onInspectorEvent(_: *anyopaque, _: []const u8) void {}
pub fn onRunMessageLoopOnPause(_: *anyopaque, _: u32) void {}
pub fn onQuitMessageLoopOnPause(_: *anyopaque) void {}
};
fn fromData(data: *anyopaque) *Inspector {
return @ptrCast(@alignCast(data));
}
pub fn getTaggedAnyOpaque(value: *const v8.Value) ?*js.TaggedAnyOpaque {
pub fn getTaggedOpaque(value: *const v8.Value) ?*TaggedOpaque {
if (!v8.v8__Value__IsObject(value)) {
return null;
}
@@ -424,24 +380,25 @@ pub export fn v8_inspector__Client__IMPL__generateUniqueId(
data: *anyopaque,
) callconv(.c) i64 {
const inspector: *Inspector = @ptrCast(@alignCast(data));
return inspector.rnd.random().int(i64);
const unique_id = inspector.unique_id + 1;
inspector.unique_id = unique_id;
return unique_id;
}
pub export fn v8_inspector__Client__IMPL__runMessageLoopOnPause(
_: *v8.InspectorClientImpl,
data: *anyopaque,
ctx_group_id: c_int,
context_group_id: c_int,
) callconv(.c) void {
const inspector: *Inspector = @ptrCast(@alignCast(data));
inspector.channel.onRunMessageLoopOnPause(inspector.channel.ctx, @intCast(ctx_group_id));
_ = data;
_ = context_group_id;
}
pub export fn v8_inspector__Client__IMPL__quitMessageLoopOnPause(
_: *v8.InspectorClientImpl,
data: *anyopaque,
) callconv(.c) void {
const inspector: *Inspector = @ptrCast(@alignCast(data));
inspector.channel.onQuitMessageLoopOnPause(inspector.channel.ctx);
_ = data;
}
pub export fn v8_inspector__Client__IMPL__runIfWaitingForDebugger(
@@ -469,7 +426,8 @@ pub export fn v8_inspector__Client__IMPL__ensureDefaultContextInGroup(
data: *anyopaque,
) callconv(.c) ?*const v8.Context {
const inspector: *Inspector = @ptrCast(@alignCast(data));
return inspector.default_context;
const global_handle = inspector.default_context orelse return null;
return v8.v8__Global__Get(&global_handle, inspector.isolate);
}
pub export fn v8_inspector__Channel__IMPL__sendResponse(
@@ -479,8 +437,8 @@ pub export fn v8_inspector__Channel__IMPL__sendResponse(
msg: [*c]u8,
length: usize,
) callconv(.c) void {
const inspector: *Inspector = @ptrCast(@alignCast(data));
inspector.channel.resp(@as(u32, @intCast(call_id)), msg[0..length]);
const session: *Session = @ptrCast(@alignCast(data));
session.onResp(session.ctx, @intCast(call_id), msg[0..length]);
}
pub export fn v8_inspector__Channel__IMPL__sendNotification(
@@ -489,8 +447,8 @@ pub export fn v8_inspector__Channel__IMPL__sendNotification(
msg: [*c]u8,
length: usize,
) callconv(.c) void {
const inspector: *Inspector = @ptrCast(@alignCast(data));
inspector.channel.notif(msg[0..length]);
const session: *Session = @ptrCast(@alignCast(data));
session.onNotif(session.ctx, msg[0..length]);
}
pub export fn v8_inspector__Channel__IMPL__flushProtocolNotifications(

View File

@@ -57,6 +57,16 @@ pub fn lowMemoryNotification(self: Isolate) void {
v8.v8__Isolate__LowMemoryNotification(self.handle);
}
pub const MemoryPressureLevel = enum(u32) {
none = v8.kNone,
moderate = v8.kModerate,
critical = v8.kCritical,
};
pub fn memoryPressureNotification(self: Isolate, level: MemoryPressureLevel) void {
v8.v8__Isolate__MemoryPressureNotification(self.handle, @intFromEnum(level));
}
pub fn notifyContextDisposed(self: Isolate) void {
_ = v8.v8__Isolate__ContextDisposedNotification(self.handle);
}

1321
src/browser/js/Local.zig Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@ const v8 = js.v8;
const Module = @This();
ctx: *js.Context,
local: *const js.Local,
handle: *const v8.Module,
pub const Status = enum(u32) {
@@ -39,21 +39,21 @@ pub fn getStatus(self: Module) Status {
pub fn getException(self: Module) js.Value {
return .{
.ctx = self.ctx,
.local = self.local,
.handle = v8.v8__Module__GetException(self.handle).?,
};
}
pub fn getModuleRequests(self: Module) Requests {
return .{
.ctx = self.ctx.handle,
.context_handle = self.local.handle,
.handle = v8.v8__Module__GetModuleRequests(self.handle).?,
};
}
pub fn instantiate(self: Module, cb: v8.ResolveModuleCallback) !bool {
var out: v8.MaybeBool = undefined;
v8.v8__Module__InstantiateModule(self.handle, self.ctx.handle, cb, &out);
v8.v8__Module__InstantiateModule(self.handle, self.local.handle, cb, &out);
if (out.has_value) {
return out.value;
}
@@ -61,15 +61,14 @@ pub fn instantiate(self: Module, cb: v8.ResolveModuleCallback) !bool {
}
pub fn evaluate(self: Module) !js.Value {
const ctx = self.ctx;
const res = v8.v8__Module__Evaluate(self.handle, ctx.handle) orelse return error.JsException;
const res = v8.v8__Module__Evaluate(self.handle, self.local.handle) orelse return error.JsException;
if (self.getStatus() == .kErrored) {
return error.JsException;
}
return .{
.ctx = ctx,
.local = self.local,
.handle = res,
};
}
@@ -80,7 +79,7 @@ pub fn getIdentityHash(self: Module) u32 {
pub fn getModuleNamespace(self: Module) js.Value {
return .{
.ctx = self.ctx,
.local = self.local,
.handle = v8.v8__Module__GetModuleNamespace(self.handle).?,
};
}
@@ -90,28 +89,24 @@ pub fn getScriptId(self: Module) u32 {
}
pub fn persist(self: Module) !Global {
var ctx = self.ctx;
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.global_modules.append(ctx.arena, global);
return .{
.handle = global,
.ctx = ctx,
};
return .{ .handle = global };
}
pub const Global = struct {
handle: v8.Global,
ctx: *js.Context,
pub fn deinit(self: *Global) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Global) Module {
pub fn local(self: *const Global, l: *const js.Local) Module {
return .{
.ctx = self.ctx,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, self.ctx.isolate.handle)),
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
@@ -121,22 +116,22 @@ pub const Global = struct {
};
const Requests = struct {
ctx: *const v8.Context,
handle: *const v8.FixedArray,
context_handle: *const v8.Context,
pub fn len(self: Requests) usize {
return @intCast(v8.v8__FixedArray__Length(self.handle));
}
pub fn get(self: Requests, idx: usize) Request {
return .{ .handle = v8.v8__FixedArray__Get(self.handle, self.ctx, @intCast(idx)).? };
return .{ .handle = v8.v8__FixedArray__Get(self.handle, self.context_handle, @intCast(idx)).? };
}
};
const Request = struct {
handle: *const v8.ModuleRequest,
pub fn specifier(self: Request) *const v8.String {
return v8.v8__ModuleRequest__GetSpecifier(self.handle).?;
pub fn specifier(self: Request, local: *const js.Local) js.String {
return .{ .local = local, .handle = v8.v8__ModuleRequest__GetSpecifier(self.handle).? };
}
};

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -22,25 +22,17 @@ const v8 = js.v8;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Context = @import("Context.zig");
const Allocator = std.mem.Allocator;
const Object = @This();
ctx: *js.Context,
local: *const js.Local,
handle: *const v8.Object,
pub fn getId(self: Object) u32 {
return @bitCast(v8.v8__Object__GetIdentityHash(self.handle));
}
pub fn has(self: Object, key: anytype) bool {
const ctx = self.ctx;
const ctx = self.local.ctx;
const key_handle = if (@TypeOf(key) == *const v8.String) key else ctx.isolate.initStringHandle(key);
var out: v8.MaybeBool = undefined;
v8.v8__Object__Has(self.handle, self.ctx.handle, key_handle, &out);
v8.v8__Object__Has(self.handle, self.local.handle, key_handle, &out);
if (out.has_value) {
return out.value;
}
@@ -48,34 +40,34 @@ pub fn has(self: Object, key: anytype) bool {
}
pub fn get(self: Object, key: anytype) !js.Value {
const ctx = self.ctx;
const ctx = self.local.ctx;
const key_handle = if (@TypeOf(key) == *const v8.String) key else ctx.isolate.initStringHandle(key);
const js_val_handle = v8.v8__Object__Get(self.handle, ctx.handle, key_handle) orelse return error.JsException;
const js_val_handle = v8.v8__Object__Get(self.handle, self.local.handle, key_handle) orelse return error.JsException;
return .{
.ctx = ctx,
.local = self.local,
.handle = js_val_handle,
};
}
pub fn set(self: Object, key: anytype, value: anytype, comptime opts: js.bridge.Caller.CallOpts) !bool {
const ctx = self.ctx;
pub fn set(self: Object, key: anytype, value: anytype, comptime opts: js.Caller.CallOpts) !bool {
const ctx = self.local.ctx;
const js_value = try ctx.zigValueToJs(value, opts);
const js_value = try self.local.zigValueToJs(value, opts);
const key_handle = if (@TypeOf(key) == *const v8.String) key else ctx.isolate.initStringHandle(key);
var out: v8.MaybeBool = undefined;
v8.v8__Object__Set(self.handle, ctx.handle, key_handle, js_value.handle, &out);
v8.v8__Object__Set(self.handle, self.local.handle, key_handle, js_value.handle, &out);
return out.has_value;
}
pub fn defineOwnProperty(self: Object, name: []const u8, value: js.Value, attr: v8.PropertyAttribute) ?bool {
const ctx = self.ctx;
const ctx = self.local.ctx;
const name_handle = ctx.isolate.initStringHandle(name);
var out: v8.MaybeBool = undefined;
v8.v8__Object__DefineOwnProperty(self.handle, ctx.handle, @ptrCast(name_handle), value.handle, attr, &out);
v8.v8__Object__DefineOwnProperty(self.handle, self.local.handle, @ptrCast(name_handle), value.handle, attr, &out);
if (out.has_value) {
return out.value;
@@ -84,53 +76,46 @@ pub fn defineOwnProperty(self: Object, name: []const u8, value: js.Value, attr:
}
}
pub fn toString(self: Object) ![]const u8 {
return self.ctx.valueToString(self.toValue(), .{});
}
pub fn toValue(self: Object) js.Value {
return .{
.ctx = self.ctx,
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
pub fn format(self: Object, writer: *std.Io.Writer) !void {
if (comptime IS_DEBUG) {
return self.ctx.debugValue(self.toValue(), writer);
return self.local.ctx.debugValue(self.toValue(), writer);
}
const str = self.toString() catch return error.WriteFailed;
return writer.writeAll(str);
}
pub fn persist(self: Object) !Global {
var ctx = self.ctx;
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.global_objects.append(ctx.arena, global);
return .{
.handle = global,
.ctx = ctx,
};
return .{ .handle = global };
}
pub fn getFunction(self: Object, name: []const u8) !?js.Function {
if (self.isNullOrUndefined()) {
return null;
}
const ctx = self.ctx;
const local = self.local;
const js_name = ctx.isolate.initStringHandle(name);
const js_val_handle = v8.v8__Object__Get(self.handle, ctx.handle, js_name) orelse return error.JsException;
const js_name = local.isolate.initStringHandle(name);
const js_val_handle = v8.v8__Object__Get(self.handle, local.handle, js_name) orelse return error.JsException;
if (v8.v8__Value__IsFunction(js_val_handle) == false) {
return null;
}
return .{
.ctx = ctx,
.local = local,
.handle = @ptrCast(js_val_handle),
};
}
@@ -145,51 +130,48 @@ pub fn isNullOrUndefined(self: Object) bool {
}
pub fn getOwnPropertyNames(self: Object) js.Array {
const handle = v8.v8__Object__GetOwnPropertyNames(self.handle, self.ctx.handle).?;
const handle = v8.v8__Object__GetOwnPropertyNames(self.handle, self.local.handle).?;
return .{
.ctx = self.ctx,
.local = self.local,
.handle = handle,
};
}
pub fn getPropertyNames(self: Object) js.Array {
const handle = v8.v8__Object__GetPropertyNames(self.handle, self.ctx.handle).?;
const handle = v8.v8__Object__GetPropertyNames(self.handle, self.local.handle).?;
return .{
.ctx = self.ctx,
.local = self.local,
.handle = handle,
};
}
pub fn nameIterator(self: Object) NameIterator {
const ctx = self.ctx;
const handle = v8.v8__Object__GetPropertyNames(self.handle, ctx.handle).?;
const handle = v8.v8__Object__GetPropertyNames(self.handle, self.local.handle).?;
const count = v8.v8__Array__Length(handle);
return .{
.ctx = ctx,
.local = self.local,
.handle = handle,
.count = count,
};
}
pub fn toZig(self: Object, comptime T: type) !T {
const js_value = js.Value{ .ctx = self.ctx, .handle = @ptrCast(self.handle) };
return self.ctx.jsValueToZig(T, js_value);
const js_value = js.Value{ .local = self.local, .handle = @ptrCast(self.handle) };
return self.local.jsValueToZig(T, js_value);
}
pub const Global = struct {
handle: v8.Global,
ctx: *js.Context,
pub fn deinit(self: *Global) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Global) Object {
pub fn local(self: *const Global, l: *const js.Local) Object {
return .{
.ctx = self.ctx,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, self.ctx.isolate.handle)),
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
@@ -201,7 +183,7 @@ pub const Global = struct {
pub const NameIterator = struct {
count: u32,
idx: u32 = 0,
ctx: *Context,
local: *const js.Local,
handle: *const v8.Array,
pub fn next(self: *NameIterator) !?[]const u8 {
@@ -211,8 +193,8 @@ pub const NameIterator = struct {
}
self.idx += 1;
const js_val_handle = v8.v8__Object__GetIndex(@ptrCast(self.handle), self.ctx.handle, idx) orelse return error.JsException;
const js_val = js.Value{ .ctx = self.ctx, .handle = js_val_handle };
return try self.ctx.valueToString(js_val, .{});
const local = self.local;
const js_val_handle = v8.v8__Object__GetIndex(@ptrCast(self.handle), local.handle, idx) orelse return error.JsException;
return try js.Value.toStringSlice(.{ .local = local, .handle = js_val_handle });
}
};

View File

@@ -21,63 +21,75 @@ const v8 = js.v8;
const Promise = @This();
ctx: *js.Context,
local: *const js.Local,
handle: *const v8.Promise,
pub fn toObject(self: Promise) js.Object {
return .{
.ctx = self.ctx,
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
pub fn toValue(self: Promise) js.Value {
return .{
.ctx = self.ctx,
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
pub fn thenAndCatch(self: Promise, on_fulfilled: js.Function, on_rejected: js.Function) !Promise {
if (v8.v8__Promise__Then2(self.handle, self.ctx.handle, on_fulfilled.handle, on_rejected.handle)) |handle| {
if (v8.v8__Promise__Then2(self.handle, self.local.handle, on_fulfilled.handle, on_rejected.handle)) |handle| {
return .{
.ctx = self.ctx,
.local = self.local,
.handle = handle,
};
}
return error.PromiseChainFailed;
}
pub fn persist(self: Promise) !Global {
var ctx = self.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.global_promises.append(ctx.arena, global);
return .{
.handle = global,
.ctx = ctx,
};
return self._persist(true);
}
pub const Global = struct {
handle: v8.Global,
ctx: *js.Context,
pub fn temp(self: Promise) !Temp {
return self._persist(false);
}
pub fn deinit(self: *Global) void {
v8.v8__Global__Reset(&self.handle);
}
fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Global else Temp) {
var ctx = self.local.ctx;
pub fn local(self: *const Global) Promise {
return .{
.ctx = self.ctx,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, self.ctx.isolate.handle)),
};
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.global_promises.append(ctx.arena, global);
} else {
try ctx.global_promises_temp.put(ctx.arena, global.data_ptr, global);
}
return .{ .handle = global };
}
pub fn isEqual(self: *const Global, other: Promise) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
pub const Temp = G(0);
pub const Global = G(1);
pub fn promise(self: *const Global) Promise {
return self.local();
}
};
fn G(comptime discriminator: u8) type {
return struct {
handle: v8.Global,
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
const Self = @This();
pub fn deinit(self: *Self) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Self, l: *const js.Local) Promise {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
};
}

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -19,6 +19,23 @@
const js = @import("js.zig");
const v8 = js.v8;
const Name = @This();
const PromiseRejection = @This();
handle: *const v8.Name,
local: *const js.Local,
handle: *const v8.PromiseRejectMessage,
pub fn promise(self: PromiseRejection) js.Promise {
return .{
.local = self.local,
.handle = v8.v8__PromiseRejectMessage__GetPromise(self.handle).?,
};
}
pub fn reason(self: PromiseRejection) ?js.Value {
const value_handle = v8.v8__PromiseRejectMessage__GetValue(self.handle) orelse return null;
return .{
.local = self.local,
.handle = value_handle,
};
}

View File

@@ -22,19 +22,19 @@ const log = @import("../../log.zig");
const PromiseResolver = @This();
ctx: *js.Context,
local: *const js.Local,
handle: *const v8.PromiseResolver,
pub fn init(ctx: *js.Context) PromiseResolver {
pub fn init(local: *const js.Local) PromiseResolver {
return .{
.ctx = ctx,
.handle = v8.v8__Promise__Resolver__New(ctx.handle).?,
.local = local,
.handle = v8.v8__Promise__Resolver__New(local.handle).?,
};
}
pub fn promise(self: PromiseResolver) js.Promise {
return .{
.ctx = self.ctx,
.local = self.local,
.handle = v8.v8__Promise__Resolver__GetPromise(self.handle).?,
};
}
@@ -46,15 +46,15 @@ pub fn resolve(self: PromiseResolver, comptime source: []const u8, value: anytyp
}
fn _resolve(self: PromiseResolver, value: anytype) !void {
const ctx: *js.Context = @constCast(self.ctx);
const js_value = try ctx.zigValueToJs(value, .{});
const local = self.local;
const js_val = try local.zigValueToJs(value, .{});
var out: v8.MaybeBool = undefined;
v8.v8__Promise__Resolver__Resolve(self.handle, self.ctx.handle, js_value.handle, &out);
v8.v8__Promise__Resolver__Resolve(self.handle, self.local.handle, js_val.handle, &out);
if (!out.has_value or !out.value) {
return error.FailedToResolvePromise;
}
ctx.runMicrotasks();
local.runMicrotasks();
}
pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype) void {
@@ -64,44 +64,36 @@ pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype
}
fn _reject(self: PromiseResolver, value: anytype) !void {
const ctx = self.ctx;
const js_value = try ctx.zigValueToJs(value, .{});
const local = self.local;
const js_val = try local.zigValueToJs(value, .{});
var out: v8.MaybeBool = undefined;
v8.v8__Promise__Resolver__Reject(self.handle, ctx.handle, js_value.handle, &out);
v8.v8__Promise__Resolver__Reject(self.handle, local.handle, js_val.handle, &out);
if (!out.has_value or !out.value) {
return error.FailedToRejectPromise;
}
ctx.runMicrotasks();
local.runMicrotasks();
}
pub fn persist(self: PromiseResolver) !Global {
var ctx = self.ctx;
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.global_promise_resolvers.append(ctx.arena, global);
return .{
.handle = global,
.ctx = ctx,
};
return .{ .handle = global };
}
pub const Global = struct {
handle: v8.Global,
ctx: *js.Context,
pub fn deinit(self: *Global) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Global) PromiseResolver {
pub fn local(self: *const Global, l: *const js.Local) PromiseResolver {
return .{
.ctx = self.ctx,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, self.ctx.isolate.handle)),
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
pub fn isEqual(self: *const Global, other: PromiseResolver) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
};

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -19,8 +19,8 @@
const std = @import("std");
const builtin = @import("builtin");
const log = @import("../log.zig");
const milliTimestamp = @import("../datetime.zig").milliTimestamp;
const log = @import("../../log.zig");
const milliTimestamp = @import("../../datetime.zig").milliTimestamp;
const IS_DEBUG = builtin.mode == .Debug;
@@ -47,9 +47,15 @@ pub fn init(allocator: std.mem.Allocator) Scheduler {
};
}
pub fn deinit(self: *Scheduler) void {
finalizeTasks(&self.low_priority);
finalizeTasks(&self.high_priority);
}
const AddOpts = struct {
name: []const u8 = "",
low_priority: bool = false,
finalizer: ?Finalizer = null,
};
pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts: AddOpts) !void {
if (comptime IS_DEBUG) {
@@ -63,6 +69,7 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts
.callback = cb,
.sequence = seq,
.name = opts.name,
.finalizer = opts.finalizer,
.run_at = milliTimestamp(.monotonic) + run_in_ms,
});
}
@@ -72,6 +79,11 @@ pub fn run(self: *Scheduler) !?u64 {
return self.runQueue(&self.high_priority);
}
pub fn hasReadyTasks(self: *Scheduler) bool {
const now = milliTimestamp(.monotonic);
return queueuHasReadyTask(&self.low_priority, now) or queueuHasReadyTask(&self.high_priority, now);
}
fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
if (queue.count() == 0) {
return null;
@@ -105,12 +117,28 @@ fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
return null;
}
fn queueuHasReadyTask(queue: *Queue, now: u64) bool {
const task = queue.peek() orelse return false;
return task.run_at <= now;
}
fn finalizeTasks(queue: *Queue) void {
var it = queue.iterator();
while (it.next()) |t| {
if (t.finalizer) |func| {
func(t.ctx);
}
}
}
const Task = struct {
run_at: u64,
sequence: u64,
ctx: *anyopaque,
name: []const u8,
callback: Callback,
finalizer: ?Finalizer,
};
const Callback = *const fn (ctx: *anyopaque) anyerror!?u32;
const Finalizer = *const fn (ctx: *anyopaque) void;

View File

@@ -22,7 +22,6 @@ const bridge = @import("bridge.zig");
const log = @import("../../log.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
const Window = @import("../webapi/Window.zig");
const v8 = js.v8;
const JsApis = bridge.JsApis;
@@ -114,20 +113,6 @@ fn isValid(self: Snapshot) bool {
return v8.v8__StartupData__IsValid(self.startup_data);
}
pub fn createGlobalTemplate(isolate: *v8.Isolate, templates: anytype) *const v8.ObjectTemplate {
// Set up the global template to inherit from Window's template
// This way the global object gets all Window properties through inheritance
const js_global = v8.v8__FunctionTemplate__New__DEFAULT(isolate);
const window_name = v8.v8__String__NewFromUtf8(isolate, "Window", v8.kNormal, 6);
v8.v8__FunctionTemplate__SetClassName(js_global, window_name);
// Find Window in JsApis by name (avoids circular import)
const window_index = comptime bridge.JsApiLookup.getId(Window.JsApi);
v8.v8__FunctionTemplate__Inherit(js_global, templates[window_index]);
return v8.v8__FunctionTemplate__InstanceTemplate(js_global).?;
}
pub fn create() !Snapshot {
var external_references = collectExternalReferences();
@@ -169,8 +154,7 @@ pub fn create() !Snapshot {
// Set up the global template to inherit from Window's template
// This way the global object gets all Window properties through inheritance
const global_template = createGlobalTemplate(isolate, templates[0..]);
const context = v8.v8__Context__New(isolate, global_template, null);
const context = v8.v8__Context__New(isolate, null, null);
v8.v8__Context__Enter(context);
defer v8.v8__Context__Exit(context);
@@ -277,12 +261,26 @@ pub fn create() !Snapshot {
};
}
// Helper to check if a JsApi has a NamedIndexed handler
fn hasNamedIndexedGetter(comptime JsApi: type) bool {
const declarations = @typeInfo(JsApi).@"struct".decls;
inline for (declarations) |d| {
const value = @field(JsApi, d.name);
const T = @TypeOf(value);
if (T == bridge.NamedIndexed) {
return true;
}
}
return false;
}
// Count total callbacks needed for external_references array
fn countExternalReferences() comptime_int {
@setEvalBranchQuota(100_000);
// +1 for the illegal constructor callback
var count: comptime_int = 1;
var has_non_template_property: bool = false;
inline for (JsApis) |JsApi| {
// Constructor (only if explicit)
@@ -305,6 +303,10 @@ fn countExternalReferences() comptime_int {
if (value.setter != null) count += 1; // setter
} else if (T == bridge.Function) {
count += 1;
} else if (T == bridge.Property) {
if (value.template == false) {
has_non_template_property = true;
}
} else if (T == bridge.Iterator) {
count += 1;
} else if (T == bridge.Indexed) {
@@ -317,6 +319,19 @@ fn countExternalReferences() comptime_int {
}
}
if (has_non_template_property) {
count += 1;
}
// In debug mode, add unknown property callbacks for types without NamedIndexed
if (comptime IS_DEBUG) {
inline for (JsApis) |JsApi| {
if (!hasNamedIndexedGetter(JsApi)) {
count += 1;
}
}
}
return count + 1; // +1 for null terminator
}
@@ -327,6 +342,8 @@ fn collectExternalReferences() [countExternalReferences()]isize {
references[idx] = @bitCast(@intFromPtr(&illegalConstructorCallback));
idx += 1;
var has_non_template_property = false;
inline for (JsApis) |JsApi| {
if (@hasDecl(JsApi, "constructor")) {
references[idx] = @bitCast(@intFromPtr(JsApi.constructor.func));
@@ -352,6 +369,10 @@ fn collectExternalReferences() [countExternalReferences()]isize {
} else if (T == bridge.Function) {
references[idx] = @bitCast(@intFromPtr(value.func));
idx += 1;
} else if (T == bridge.Property) {
if (value.template == false) {
has_non_template_property = true;
}
} else if (T == bridge.Iterator) {
references[idx] = @bitCast(@intFromPtr(value.func));
idx += 1;
@@ -373,6 +394,21 @@ fn collectExternalReferences() [countExternalReferences()]isize {
}
}
if (has_non_template_property) {
references[idx] = @bitCast(@intFromPtr(&bridge.Property.getter));
idx += 1;
}
// In debug mode, collect unknown property callbacks for types without NamedIndexed
if (comptime IS_DEBUG) {
inline for (JsApis) |JsApi| {
if (!hasNamedIndexedGetter(JsApi)) {
references[idx] = @bitCast(@intFromPtr(bridge.unknownObjectPropertyCallback(JsApi)));
idx += 1;
}
}
}
return references;
}
@@ -409,6 +445,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
const instance = v8.v8__FunctionTemplate__InstanceTemplate(template);
const declarations = @typeInfo(JsApi).@"struct".decls;
var has_named_index_getter = false;
inline for (declarations) |d| {
const name: [:0]const u8 = d.name;
@@ -418,7 +455,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
switch (definition) {
bridge.Accessor => {
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, value.getter).?);
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.getter }).?);
if (value.setter == null) {
if (value.static) {
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback);
@@ -429,12 +466,12 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
if (comptime IS_DEBUG) {
std.debug.assert(value.static == false);
}
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, value.setter.?).?);
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?);
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(target, js_name, getter_callback, setter_callback);
}
},
bridge.Function => {
const function_template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, value.func).?);
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func, .length = value.arity }).?);
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
if (value.static) {
v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
@@ -469,9 +506,10 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
};
v8.v8__ObjectTemplate__SetNamedHandler(instance, &configuration);
has_named_index_getter = true;
},
bridge.Iterator => {
const function_template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, value.func).?);
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?);
const js_name = if (value.async)
v8.v8__Symbol__GetAsyncIterator(isolate)
else
@@ -479,17 +517,29 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
v8.v8__Template__Set(@ptrCast(target), js_name, @ptrCast(function_template), v8.None);
},
bridge.Property => {
// simpleZigValueToJs now returns raw handle directly
const js_value = switch (value) {
.int => |v| js.simpleZigValueToJs(.{ .handle = isolate }, v, true, false),
const js_value = switch (value.value) {
.null => js.simpleZigValueToJs(.{ .handle = isolate }, null, true, false),
inline .bool, .int, .float, .string => |v| js.simpleZigValueToJs(.{ .handle = isolate }, v, true, false),
};
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
// apply it both to the type itself
v8.v8__Template__Set(@ptrCast(template), js_name, js_value, v8.ReadOnly + v8.DontDelete);
// and to instances of the type
v8.v8__Template__Set(@ptrCast(target), js_name, js_value, v8.ReadOnly + v8.DontDelete);
if (value.template == false) {
// not defined on the template, only on the instance. This
// is like an Accessor, but because the value is known at
// compile time, we skip _a lot_ of code and quickly return
// the hard-coded value
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = bridge.Property.getter,
.data = js_value,
.side_effect_type = v8.kSideEffectType_HasSideEffectToReceiver,
}));
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(target, js_name, getter_callback);
} else {
// apply it both to the type itself
v8.v8__Template__Set(@ptrCast(template), js_name, js_value, v8.ReadOnly + v8.DontDelete);
// and to instances of the type
v8.v8__Template__Set(@ptrCast(target), js_name, js_value, v8.ReadOnly + v8.DontDelete);
}
},
bridge.Constructor => {}, // already handled in generateConstructor
else => {},
@@ -506,6 +556,23 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
const js_value = v8.v8__String__NewFromUtf8(isolate, JsApi.Meta.name.ptr, v8.kNormal, @intCast(JsApi.Meta.name.len));
v8.v8__Template__Set(@ptrCast(instance), js_name, js_value, v8.ReadOnly + v8.DontDelete);
}
if (comptime IS_DEBUG) {
if (!has_named_index_getter) {
var configuration: v8.NamedPropertyHandlerConfiguration = .{
.getter = bridge.unknownObjectPropertyCallback(JsApi),
.setter = null,
.query = null,
.deleter = null,
.enumerator = null,
.definer = null,
.descriptor = null,
.data = null,
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
};
v8.v8__ObjectTemplate__SetNamedHandler(instance, &configuration);
}
}
}
fn protoIndexLookup(comptime JsApi: type) ?bridge.JsApiLookup.BackingInt {

View File

@@ -18,36 +18,94 @@
const std = @import("std");
const js = @import("js.zig");
const SSO = @import("../../string.zig").String;
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const v8 = js.v8;
const String = @This();
ctx: *js.Context,
local: *const js.Local,
handle: *const v8.String,
pub const ToZigOpts = struct {
allocator: ?Allocator = null,
};
pub fn toZig(self: String, opts: ToZigOpts) ![]u8 {
return self._toZig(false, opts);
pub fn toSlice(self: String) ![]u8 {
return self._toSlice(false, self.local.call_arena);
}
pub fn toZigZ(self: String, opts: ToZigOpts) ![:0]u8 {
return self._toZig(true, opts);
pub fn toSliceZ(self: String) ![:0]u8 {
return self._toSlice(true, self.local.call_arena);
}
pub fn toSliceWithAlloc(self: String, allocator: Allocator) ![]u8 {
return self._toSlice(false, allocator);
}
fn _toSlice(self: String, comptime null_terminate: bool, allocator: Allocator) !(if (null_terminate) [:0]u8 else []u8) {
const local = self.local;
const handle = self.handle;
const isolate = local.isolate.handle;
fn _toZig(self: String, comptime null_terminate: bool, opts: ToZigOpts) !(if (null_terminate) [:0]u8 else []u8) {
const isolate = self.ctx.isolate.handle;
const allocator = opts.allocator orelse self.ctx.call_arena;
const len: u32 = @intCast(v8.v8__String__Utf8Length(self.handle, isolate));
const buf = if (null_terminate) try allocator.allocSentinel(u8, len, 0) else try allocator.alloc(u8, len);
const len = v8.v8__String__Utf8Length(handle, isolate);
const buf = try (if (comptime null_terminate) allocator.allocSentinel(u8, @intCast(len), 0) else allocator.alloc(u8, @intCast(len)));
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
if (comptime IS_DEBUG) {
std.debug.assert(n == len);
}
const options = v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8;
const n = v8.v8__String__WriteUtf8(self.handle, isolate, buf.ptr, buf.len, options);
std.debug.assert(n == len);
return buf;
}
pub fn toSSO(self: String, comptime global: bool) !(if (global) SSO.Global else SSO) {
if (comptime global) {
return .{ .str = try self.toSSOWithAlloc(self.local.ctx.arena) };
}
return self.toSSOWithAlloc(self.local.call_arena);
}
pub fn toSSOWithAlloc(self: String, allocator: Allocator) !SSO {
const handle = self.handle;
const isolate = self.local.isolate.handle;
const len: usize = @intCast(v8.v8__String__Utf8Length(handle, isolate));
if (len <= 12) {
var content: [12]u8 = undefined;
const n = v8.v8__String__WriteUtf8(handle, isolate, &content[0], content.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
if (comptime IS_DEBUG) {
std.debug.assert(n == len);
}
// Weird that we do this _after_, but we have to..I've seen weird issues
// in ReleaseMode where v8 won't write to content if it starts off zero
// initiated
@memset(content[len..], 0);
return .{ .len = @intCast(len), .payload = .{ .content = content } };
}
const buf = try allocator.alloc(u8, len);
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
if (comptime IS_DEBUG) {
std.debug.assert(n == len);
}
var prefix: [4]u8 = @splat(0);
@memcpy(&prefix, buf[0..4]);
return .{
.len = @intCast(len),
.payload = .{ .heap = .{
.prefix = prefix,
.ptr = buf.ptr,
} },
};
}
pub fn format(self: String, writer: *std.Io.Writer) !void {
const local = self.local;
const handle = self.handle;
const isolate = local.isolate.handle;
var small: [1024]u8 = undefined;
const len = v8.v8__String__Utf8Length(handle, isolate);
var buf = if (len < 1024) &small else local.call_arena.alloc(u8, @intCast(len)) catch return error.WriteFailed;
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
return writer.writeAll(buf[0..n]);
}

View File

@@ -0,0 +1,156 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const js = @import("js.zig");
const v8 = js.v8;
const bridge = js.bridge;
// When we return a Zig object to V8, we put it on the heap and pass it into
// v8 as an *anyopaque (i.e. void *). When V8 gives us back the value, say, as a
// function parameter, we know what type it _should_ be.
//
// In a simple/perfect world, we could use this knowledge to cast the *anyopaque
// to the parameter type:
// const arg: @typeInfo(@TypeOf(function)).@"fn".params[0] = @ptrCast(v8_data);
//
// But there are 2 reasons we can't do that.
//
// == Reason 1 ==
// The JS code might pass the wrong type:
//
// var cat = new Cat();
// cat.setOwner(new Cat());
//
// The zig_setOwner method expects the 2nd parameter to be an *Owner, but
// the JS code passed a *Cat.
//
// To solve this issue, we tag every returned value so that we can check what
// type it is. In the above case, we'd expect an *Owner, but the tag would tell
// us that we got a *Cat. We use the type index in our Types lookup as the tag.
//
// == Reason 2 ==
// Because of prototype inheritance, even "correct" code can be a challenge. For
// example, say the above JavaScript is fixed:
//
// var cat = new Cat();
// cat.setOwner(new Owner("Leto"));
//
// The issue is that setOwner might not expect an *Owner, but rather a
// *Person, which is the prototype for Owner. Now our Zig code is expecting
// a *Person, but it was (correctly) given an *Owner.
// For this reason, we also store the prototype chain.
const TaggedOpaque = @This();
prototype_len: u16,
prototype_chain: [*]const PrototypeChainEntry,
// Ptr to the Zig instance. Between the context where it's called (i.e.
// we have the comptime parameter info for all functions), and the index field
// we can figure out what type this is.
value: *anyopaque,
// When we're asked to describe an object via the Inspector, we _must_ include
// the proper subtype (and description) fields in the returned JSON.
// V8 will give us a Value and ask us for the subtype. From the js.Value we
// can get a js.Object, and from the js.Object, we can get out TaggedOpaque
// which is where we store the subtype.
subtype: ?bridge.SubType,
pub const PrototypeChainEntry = struct {
index: bridge.JsApiLookup.BackingInt,
offset: u16, // offset to the _proto field
};
// Reverses the mapZigInstanceToJs, making sure that our TaggedOpaque
// contains a ptr to the correct type.
pub fn fromJS(comptime R: type, js_obj_handle: *const v8.Object) !R {
const ti = @typeInfo(R);
if (ti != .pointer) {
@compileError("non-pointer Zig parameter type: " ++ @typeName(R));
}
const T = ti.pointer.child;
const JsApi = bridge.Struct(T).JsApi;
if (@hasDecl(JsApi.Meta, "empty_with_no_proto")) {
// Empty structs aren't stored as TOAs and there's no data
// stored in the JSObject's IntenrnalField. Why bother when
// we can just return an empty struct here?
return @constCast(@as(*const T, &.{}));
}
const internal_field_count = v8.v8__Object__InternalFieldCount(js_obj_handle);
// Special case for Window: the global object doesn't have internal fields
// Window instance is stored in context.page.window instead
if (internal_field_count == 0) {
// Normally, this would be an error. All JsObject that map to a Zig type
// are either `empty_with_no_proto` (handled above) or have an
// interalFieldCount. The only exception to that is the Window...
const isolate = v8.v8__Object__GetIsolate(js_obj_handle).?;
const context = js.Context.fromIsolate(.{ .handle = isolate });
const Window = @import("../webapi/Window.zig");
if (T == Window) {
return context.page.window;
}
// ... Or the window's prototype.
// We could make this all comptime-fancy, but it's easier to hard-code
// the EventTarget
const EventTarget = @import("../webapi/EventTarget.zig");
if (T == EventTarget) {
return context.page.window._proto;
}
// Type not found in Window's prototype chain
return error.InvalidArgument;
}
// if it isn't an empty struct, then the v8.Object should have an
// InternalFieldCount > 0, since our toa pointer should be embedded
// at index 0 of the internal field count.
if (internal_field_count == 0) {
return error.InvalidArgument;
}
if (!bridge.JsApiLookup.has(JsApi)) {
@compileError("unknown Zig type: " ++ @typeName(R));
}
const internal_field_handle = v8.v8__Object__GetInternalField(js_obj_handle, 0).?;
const tao: *TaggedOpaque = @ptrCast(@alignCast(v8.v8__External__Value(internal_field_handle)));
const expected_type_index = bridge.JsApiLookup.getId(JsApi);
const prototype_chain = tao.prototype_chain[0..tao.prototype_len];
if (prototype_chain[0].index == expected_type_index) {
return @ptrCast(@alignCast(tao.value));
}
// Ok, let's walk up the chain
var ptr = @intFromPtr(tao.value);
for (prototype_chain[1..]) |proto| {
ptr += proto.offset; // the offset to the _proto field
const proto_ptr: **anyopaque = @ptrFromInt(ptr);
if (proto.index == expected_type_index) {
return @ptrCast(@alignCast(proto_ptr.*));
}
ptr = @intFromPtr(proto_ptr.*);
}
return error.InvalidArgument;
}

View File

@@ -20,47 +20,77 @@ const std = @import("std");
const js = @import("js.zig");
const v8 = js.v8;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Allocator = std.mem.Allocator;
const TryCatch = @This();
ctx: *js.Context,
handle: v8.TryCatch,
local: *const js.Local,
pub fn init(self: *TryCatch, ctx: *js.Context) void {
self.ctx = ctx;
v8.v8__TryCatch__CONSTRUCT(&self.handle, ctx.isolate.handle);
pub fn init(self: *TryCatch, l: *const js.Local) void {
self.local = l;
v8.v8__TryCatch__CONSTRUCT(&self.handle, l.isolate.handle);
}
pub fn hasCaught(self: TryCatch) bool {
return v8.v8__TryCatch__HasCaught(&self.handle);
}
pub fn rethrow(self: *TryCatch) void {
if (comptime IS_DEBUG) {
std.debug.assert(self.hasCaught());
}
_ = v8.v8__TryCatch__ReThrow(&self.handle);
}
pub fn caught(self: TryCatch, allocator: Allocator) ?Caught {
if (!self.hasCaught()) {
if (self.hasCaught() == false) {
return null;
}
const ctx = self.ctx;
var hs: js.HandleScope = undefined;
hs.init(ctx.isolate);
defer hs.deinit();
const l = self.local;
const line: ?u32 = blk: {
const handle = v8.v8__TryCatch__Message(&self.handle) orelse return null;
const l = v8.v8__Message__GetLineNumber(handle, ctx.handle);
break :blk if (l < 0) null else @intCast(l);
const line = v8.v8__Message__GetLineNumber(handle, l.handle);
break :blk if (line < 0) null else @intCast(line);
};
const exception: ?[]const u8 = blk: {
const handle = v8.v8__TryCatch__Exception(&self.handle) orelse break :blk null;
break :blk ctx.valueToString(.{ .ctx = ctx, .handle = handle }, .{ .allocator = allocator }) catch |err| @errorName(err);
var js_val = js.Value{ .local = l, .handle = handle };
// If it's an Error object, try to get the message property
if (js_val.isObject()) {
const js_obj = js_val.toObject();
if (js_obj.has("message")) {
js_val = js_obj.get("message") catch break :blk null;
}
}
if (js_val.isString()) |js_str| {
break :blk js_str.toSliceWithAlloc(allocator) catch |err| @errorName(err);
}
break :blk null;
};
const stack: ?[]const u8 = blk: {
const handle = v8.v8__TryCatch__StackTrace(&self.handle, ctx.handle) orelse break :blk null;
break :blk ctx.valueToString(.{ .ctx = ctx, .handle = handle }, .{ .allocator = allocator }) catch |err| @errorName(err);
const handle = v8.v8__TryCatch__StackTrace(&self.handle, l.handle) orelse break :blk null;
var js_val = js.Value{ .local = l, .handle = handle };
// If it's an Error object, try to get the stack property
if (js_val.isObject()) {
const js_obj = js_val.toObject();
if (js_obj.has("stack")) {
js_val = js_obj.get("stack") catch break :blk null;
}
}
if (js_val.isString()) |js_str| {
break :blk js_str.toSliceWithAlloc(allocator) catch |err| @errorName(err);
}
break :blk null;
};
return .{
@@ -85,10 +115,10 @@ pub fn deinit(self: *TryCatch) void {
}
pub const Caught = struct {
line: ?u32,
caught: bool,
stack: ?[]const u8,
exception: ?[]const u8,
line: ?u32 = null,
caught: bool = false,
stack: ?[]const u8 = null,
exception: ?[]const u8 = null,
pub fn format(self: Caught, writer: *std.Io.Writer) !void {
const separator = @import("../../log.zig").separator();

View File

@@ -18,6 +18,7 @@
const std = @import("std");
const js = @import("js.zig");
const SSO = @import("../../string.zig").String;
const v8 = js.v8;
@@ -27,15 +28,19 @@ const Allocator = std.mem.Allocator;
const Value = @This();
ctx: *js.Context,
local: *const js.Local,
handle: *const v8.Value,
pub fn isObject(self: Value) bool {
return v8.v8__Value__IsObject(self.handle);
}
pub fn isString(self: Value) bool {
return v8.v8__Value__IsString(self.handle);
pub fn isString(self: Value) ?js.String {
const handle = self.handle;
if (!v8.v8__Value__IsString(handle)) {
return null;
}
return .{ .local = self.local, .handle = @ptrCast(handle) };
}
pub fn isArray(self: Value) bool {
@@ -155,12 +160,12 @@ pub fn isPromise(self: Value) bool {
}
pub fn toBool(self: Value) bool {
return v8.v8__Value__BooleanValue(self.handle, self.ctx.isolate.handle);
return v8.v8__Value__BooleanValue(self.handle, self.local.isolate.handle);
}
pub fn typeOf(self: Value) js.String {
const str_handle = v8.v8__Value__TypeOf(self.handle, self.ctx.isolate.handle).?;
return js.String{ .ctx = self.ctx, .handle = str_handle };
const str_handle = v8.v8__Value__TypeOf(self.handle, self.local.isolate.handle).?;
return js.String{ .local = self.local, .handle = str_handle };
}
pub fn toF32(self: Value) !f32 {
@@ -169,7 +174,7 @@ pub fn toF32(self: Value) !f32 {
pub fn toF64(self: Value) !f64 {
var maybe: v8.MaybeF64 = undefined;
v8.v8__Value__NumberValue(self.handle, self.ctx.handle, &maybe);
v8.v8__Value__NumberValue(self.handle, self.local.handle, &maybe);
if (!maybe.has_value) {
return error.JsException;
}
@@ -178,7 +183,7 @@ pub fn toF64(self: Value) !f64 {
pub fn toI32(self: Value) !i32 {
var maybe: v8.MaybeI32 = undefined;
v8.v8__Value__Int32Value(self.handle, self.ctx.handle, &maybe);
v8.v8__Value__Int32Value(self.handle, self.local.handle, &maybe);
if (!maybe.has_value) {
return error.JsException;
}
@@ -187,7 +192,7 @@ pub fn toI32(self: Value) !i32 {
pub fn toU32(self: Value) !u32 {
var maybe: v8.MaybeU32 = undefined;
v8.v8__Value__Uint32Value(self.handle, self.ctx.handle, &maybe);
v8.v8__Value__Uint32Value(self.handle, self.local.handle, &maybe);
if (!maybe.has_value) {
return error.JsException;
}
@@ -199,66 +204,70 @@ pub fn toPromise(self: Value) js.Promise {
std.debug.assert(self.isPromise());
}
return .{
.ctx = self.ctx,
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
pub fn toString(self: Value, opts: js.String.ToZigOpts) ![]u8 {
return self._toString(false, opts);
pub fn toString(self: Value) !js.String {
const l = self.local;
const value_handle: *const v8.Value = blk: {
if (self.isSymbol()) {
break :blk @ptrCast(v8.v8__Symbol__Description(@ptrCast(self.handle), l.isolate.handle).?);
}
break :blk self.handle;
};
const str_handle = v8.v8__Value__ToString(value_handle, l.handle) orelse return error.JsException;
return .{ .local = self.local, .handle = str_handle };
}
pub fn toStringZ(self: Value, opts: js.String.ToZigOpts) ![:0]u8 {
return self._toString(true, opts);
pub fn toSSO(self: Value, comptime global: bool) !(if (global) SSO.Global else SSO) {
return (try self.toString()).toSSO(global);
}
pub fn toSSOWithAlloc(self: Value, allocator: Allocator) !SSO {
return (try self.toString()).toSSOWithAlloc(allocator);
}
pub fn toStringSlice(self: Value) ![]u8 {
return (try self.toString()).toSlice();
}
pub fn toStringSliceZ(self: Value) ![:0]u8 {
return (try self.toString()).toSliceZ();
}
pub fn toStringSliceWithAlloc(self: Value, allocator: Allocator) ![]u8 {
return (try self.toString()).toSliceWithAlloc(allocator);
}
pub fn toJson(self: Value, allocator: Allocator) ![]u8 {
const json_str_handle = v8.v8__JSON__Stringify(self.ctx.handle, self.handle, null) orelse return error.JsException;
return self.ctx.jsStringToZig(json_str_handle, .{ .allocator = allocator });
}
fn _toString(self: Value, comptime null_terminate: bool, opts: js.String.ToZigOpts) !(if (null_terminate) [:0]u8 else []u8) {
const ctx = self.ctx;
if (self.isSymbol()) {
const sym_handle = v8.v8__Symbol__Description(@ptrCast(self.handle), ctx.isolate.handle).?;
return _toString(.{ .handle = @ptrCast(sym_handle), .ctx = ctx }, null_terminate, opts);
}
const str_handle = v8.v8__Value__ToString(self.handle, ctx.handle) orelse {
return error.JsException;
};
const str = js.String{ .ctx = ctx, .handle = str_handle };
if (comptime null_terminate) {
return js.String.toZigZ(str, opts);
}
return js.String.toZig(str, opts);
}
pub fn fromJson(ctx: *js.Context, json: []const u8) !Value {
const v8_isolate = v8.Isolate{ .handle = ctx.isolate.handle };
const json_string = v8.String.initUtf8(v8_isolate, json);
const v8_context = v8.Context{ .handle = ctx.handle };
const value = try v8.Json.parse(v8_context, json_string);
return .{ .ctx = ctx, .handle = value.handle };
const local = self.local;
const str_handle = v8.v8__JSON__Stringify(local.handle, self.handle, null) orelse return error.JsException;
return js.String.toSliceWithAlloc(.{ .local = local, .handle = str_handle }, allocator);
}
pub fn persist(self: Value) !Global {
var ctx = self.ctx;
return self._persist(true);
}
pub fn temp(self: Value) !Temp {
return self._persist(false);
}
fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Global else Temp) {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.global_values.append(ctx.arena, global);
return .{
.handle = global,
.ctx = ctx,
};
if (comptime is_global) {
try ctx.global_values.append(ctx.arena, global);
} else {
try ctx.global_values_temp.put(ctx.arena, global.data_ptr, global);
}
return .{ .handle = global };
}
pub fn toZig(self: Value, comptime T: type) !T {
return self.ctx.jsValueToZig(T, self);
return self.local.jsValueToZig(T, self);
}
pub fn toObject(self: Value) js.Object {
@@ -267,7 +276,7 @@ pub fn toObject(self: Value) js.Object {
}
return .{
.ctx = self.ctx,
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
@@ -278,7 +287,7 @@ pub fn toArray(self: Value) js.Array {
}
return .{
.ctx = self.ctx,
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
@@ -295,28 +304,37 @@ pub fn toBigInt(self: Value) js.BigInt {
pub fn format(self: Value, writer: *std.Io.Writer) !void {
if (comptime IS_DEBUG) {
return self.ctx.debugValue(self, writer);
return self.local.debugValue(self, writer);
}
const str = self.toString(.{}) catch return error.WriteFailed;
return writer.writeAll(str);
const js_str = self.toString() catch return error.WriteFailed;
return js_str.format(writer);
}
pub const Global = struct {
handle: v8.Global,
ctx: *js.Context,
pub const Temp = G(0);
pub const Global = G(1);
pub fn deinit(self: *Global) void {
v8.v8__Global__Reset(&self.handle);
}
fn G(comptime discriminator: u8) type {
return struct {
handle: v8.Global,
pub fn local(self: *const Global) Value {
return .{
.ctx = self.ctx,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, self.ctx.isolate.handle)),
};
}
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
pub fn isEqual(self: *const Global, other: Value) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
};
const Self = @This();
pub fn deinit(self: *Self) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Self, l: *const js.Local) Value {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
pub fn isEqual(self: *const Self, other: Value) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +0,0 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("js.zig");
const v8 = js.v8;
pub fn Global(comptime T: type) type {
const H = @FieldType(T, "handle");
return struct {
global: v8.Global,
const Self = @This();
pub fn init(isolate: *v8.Isolate, handle: H) Self {
var global: v8.Global = undefined;
v8.v8__Global__New(isolate, handle, &global);
return .{
.global = global,
};
}
pub fn deinit(self: *Self) void {
v8.v8__Global__Reset(&self.global);
}
pub fn local(self: *const Self) H {
return @ptrCast(@alignCast(@as(*const anyopaque, @ptrFromInt(self.global.data_ptr))));
}
};
}

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -19,19 +19,19 @@
const std = @import("std");
pub const v8 = @import("v8").c;
const log = @import("../../log.zig");
const string = @import("../../string.zig");
pub const Env = @import("Env.zig");
pub const bridge = @import("bridge.zig");
pub const ExecutionWorld = @import("ExecutionWorld.zig");
pub const Caller = @import("Caller.zig");
pub const Context = @import("Context.zig");
pub const Local = @import("Local.zig");
pub const Inspector = @import("Inspector.zig");
pub const Snapshot = @import("Snapshot.zig");
pub const Platform = @import("Platform.zig");
pub const Isolate = @import("Isolate.zig");
pub const HandleScope = @import("HandleScope.zig");
pub const Name = @import("Name.zig");
pub const Value = @import("Value.zig");
pub const Array = @import("Array.zig");
pub const String = @import("String.zig");
@@ -43,8 +43,8 @@ pub const Module = @import("Module.zig");
pub const BigInt = @import("BigInt.zig");
pub const Number = @import("Number.zig");
pub const Integer = @import("Integer.zig");
pub const Global = @import("global.zig").Global;
pub const PromiseResolver = @import("PromiseResolver.zig");
pub const PromiseRejection = @import("PromiseRejection.zig");
const Allocator = std.mem.Allocator;
@@ -78,12 +78,8 @@ pub const ArrayBuffer = struct {
};
pub const Exception = struct {
ctx: *const Context,
local: *const Local,
handle: *const v8.Value,
pub fn exception(self: Exception, allocator: Allocator) ![]const u8 {
return self.context.valueToString(self.inner, .{ .allocator = allocator });
}
};
// These are simple types that we can convert to JS with only an isolate. This
@@ -133,6 +129,7 @@ pub fn simpleZigValueToJs(isolate: Isolate, value: anytype, comptime fail: bool,
},
.@"struct" => {
switch (@TypeOf(value)) {
string.String => return isolate.initStringHandle(value.str()),
ArrayBuffer => {
const values = value.values;
const len = values.len;
@@ -215,61 +212,6 @@ pub fn simpleZigValueToJs(isolate: Isolate, value: anytype, comptime fail: bool,
}
return null;
}
// When we return a Zig object to V8, we put it on the heap and pass it into
// v8 as an *anyopaque (i.e. void *). When V8 gives us back the value, say, as a
// function parameter, we know what type it _should_ be.
//
// In a simple/perfect world, we could use this knowledge to cast the *anyopaque
// to the parameter type:
// const arg: @typeInfo(@TypeOf(function)).@"fn".params[0] = @ptrCast(v8_data);
//
// But there are 2 reasons we can't do that.
//
// == Reason 1 ==
// The JS code might pass the wrong type:
//
// var cat = new Cat();
// cat.setOwner(new Cat());
//
// The zig_setOwner method expects the 2nd parameter to be an *Owner, but
// the JS code passed a *Cat.
//
// To solve this issue, we tag every returned value so that we can check what
// type it is. In the above case, we'd expect an *Owner, but the tag would tell
// us that we got a *Cat. We use the type index in our Types lookup as the tag.
//
// == Reason 2 ==
// Because of prototype inheritance, even "correct" code can be a challenge. For
// example, say the above JavaScript is fixed:
//
// var cat = new Cat();
// cat.setOwner(new Owner("Leto"));
//
// The issue is that setOwner might not expect an *Owner, but rather a
// *Person, which is the prototype for Owner. Now our Zig code is expecting
// a *Person, but it was (correctly) given an *Owner.
// For this reason, we also store the prototype chain.
pub const TaggedAnyOpaque = struct {
prototype_len: u16,
prototype_chain: [*]const PrototypeChainEntry,
// Ptr to the Zig instance. Between the context where it's called (i.e.
// we have the comptime parameter info for all functions), and the index field
// we can figure out what type this is.
value: *anyopaque,
// When we're asked to describe an object via the Inspector, we _must_ include
// the proper subtype (and description) fields in the returned JSON.
// V8 will give us a Value and ask us for the subtype. From the js.Value we
// can get a js.Object, and from the js.Object, we can get out TaggedAnyOpaque
// which is where we store the subtype.
subtype: ?bridge.SubType,
};
pub const PrototypeChainEntry = struct {
index: bridge.JsApiLookup.BackingInt,
offset: u16, // offset to the _proto field
};
// These are here, and not in Inspector.zig, because Inspector.zig isn't always
// included (e.g. in the wpt build).
@@ -281,7 +223,7 @@ pub export fn v8_inspector__Client__IMPL__valueSubtype(
_: *v8.InspectorClientImpl,
c_value: *const v8.Value,
) callconv(.c) [*c]const u8 {
const external_entry = Inspector.getTaggedAnyOpaque(c_value) orelse return null;
const external_entry = Inspector.getTaggedOpaque(c_value) orelse return null;
return if (external_entry.subtype) |st| @tagName(st) else null;
}
@@ -298,11 +240,11 @@ pub export fn v8_inspector__Client__IMPL__descriptionForValueSubtype(
// We _must_ include a non-null description in order for the subtype value
// to be included. Besides that, I don't know if the value has any meaning
const external_entry = Inspector.getTaggedAnyOpaque(c_value) orelse return null;
const external_entry = Inspector.getTaggedOpaque(c_value) orelse return null;
return if (external_entry.subtype == null) null else "";
}
test "TaggedAnyOpaque" {
// If we grow this, fine, but it should be a conscious decision
try std.testing.expectEqual(24, @sizeOf(TaggedAnyOpaque));
try std.testing.expectEqual(24, @sizeOf(@import("TaggedOpaque.zig")));
}

View File

@@ -24,6 +24,7 @@ const Page = @import("../Page.zig");
const Node = @import("../webapi/Node.zig");
const Element = @import("../webapi/Element.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
pub const ParsedNode = struct {
node: *Node,
@@ -373,6 +374,17 @@ fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !
switch (node_or_text.toUnion()) {
.node => |cpn| {
const child = getNode(cpn);
if (child._parent) |previous_parent| {
// html5ever says this can't happen, but we might be screwing up
// the node on our side. We shouldn't be, but we're seeing this
// in the wild, and I'm not sure why. In debug, let's crash so
// we can try to figure it out. In release, let's disconnect
// the child first.
if (comptime IS_DEBUG) {
unreachable;
}
self.page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent.isConnected() });
}
try self.page.appendNew(parent, .{ .node = child });
},
.text => |txt| try self.page.appendNew(parent, .{ .text = txt }),

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -16,8 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
// Gets the Parent of child.
// HtmlElement.of(script) -> *HTMLElement
pub fn Struct(comptime T: type) type {
@@ -28,37 +26,3 @@ pub fn Struct(comptime T: type) type {
else => unreachable,
};
}
// Creates an enum of N enums. Doesn't perserve their underlying integer
pub fn mergeEnums(comptime enums: []const type) type {
const field_count = blk: {
var count: usize = 0;
inline for (enums) |e| {
count += @typeInfo(e).@"enum".fields.len;
}
break :blk count;
};
var i: usize = 0;
var fields: [field_count]std.builtin.Type.EnumField = undefined;
for (enums) |e| {
for (@typeInfo(e).@"enum".fields) |f| {
fields[i] = .{
.name = f.name,
.value = i,
};
i += 1;
}
}
return @Type(.{ .@"enum" = .{
.decls = &.{},
.tag_type = blk: {
if (field_count <= std.math.maxInt(u8)) break :blk u8;
if (field_count <= std.math.maxInt(u16)) break :blk u16;
unreachable;
},
.fields = &fields,
.is_exhaustive = true,
} });
}

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="time">
// should not crash
console.time();
console.timeLog();
console.timeEnd();
console.time("test");
console.timeLog("test");
console.timeEnd("test");
testing.expectEqual(true, true);
</script>
<script id="count">
// should not crash
console.count();
console.count();
console.countReset();
console.count("test");
console.count("test");
console.countReset("test");
testing.expectEqual(true, true);
</script>

View File

@@ -20,8 +20,10 @@
{
testing.expectEqual('\\30 abc', CSS.escape('0abc'));
testing.expectEqual('\\31 23', CSS.escape('123'));
testing.expectEqual('\\-test', CSS.escape('-test'));
testing.expectEqual('\\--test', CSS.escape('--test'));
testing.expectEqual('\\-', CSS.escape('-'));
testing.expectEqual('-test', CSS.escape('-test'));
testing.expectEqual('--test', CSS.escape('--test'));
testing.expectEqual('-\\33 ', CSS.escape('-3'));
}
</script>

View File

@@ -2,12 +2,18 @@
<body></body>
<script src="../testing.js"></script>
<script id=createElement>
const div = document.createElement('div');
testing.expectEqual("DIV", div.tagName);
div.id = "hello";
testing.expectEqual(1, document.createElement.length);
const div1 = document.createElement('div');
testing.expectEqual(true, div1 instanceof HTMLDivElement);
testing.expectEqual("DIV", div1.tagName);
div1.id = "hello";
testing.expectEqual(null, $('#hello'));
document.getElementsByTagName('body')[0].appendChild(div);
testing.expectEqual(div, $('#hello'));
const div2 = document.createElement('DIV');
testing.expectEqual(true, div2 instanceof HTMLDivElement);
document.getElementsByTagName('body')[0].appendChild(div1);
testing.expectEqual(div1, $('#hello'));
</script>

View File

@@ -2,9 +2,15 @@
<body></body>
<script src="../testing.js"></script>
<script id=createElementNS>
const htmlDiv = document.createElementNS('http://www.w3.org/1999/xhtml', 'div');
testing.expectEqual('DIV', htmlDiv.tagName);
testing.expectEqual('http://www.w3.org/1999/xhtml', htmlDiv.namespaceURI);
const htmlDiv1 = document.createElementNS('http://www.w3.org/1999/xhtml', 'div');
testing.expectEqual('DIV', htmlDiv1.tagName);
testing.expectEqual(true, htmlDiv1 instanceof HTMLDivElement);
testing.expectEqual('http://www.w3.org/1999/xhtml', htmlDiv1.namespaceURI);
const htmlDiv2 = document.createElementNS('http://www.w3.org/1999/xhtml', 'DIV');
testing.expectEqual('DIV', htmlDiv2.tagName);
testing.expectEqual(true, htmlDiv2 instanceof HTMLDivElement);
testing.expectEqual('http://www.w3.org/1999/xhtml', htmlDiv2.namespaceURI);
const svgRect = document.createElementNS('http://www.w3.org/2000/svg', 'RecT');
testing.expectEqual('RecT', svgRect.tagName);

View File

@@ -13,6 +13,10 @@
testing.expectEqual(undefined, document.getCurrentScript);
testing.expectEqual("http://127.0.0.1:9582/src/browser/tests/document/document.html", document.URL);
testing.expectEqual(window, document.defaultView);
testing.expectEqual(false, document.hidden);
testing.expectEqual("visible", document.visibilityState);
testing.expectEqual(false, document.prerendering);
testing.expectEqual(undefined, Document.prerendering);
</script>
<script id=headAndbody>

View File

@@ -41,4 +41,53 @@
testing.expectEqual("DIV", newElement.tagName);
testing.expectEqual("after begin", newElement.innerText);
testing.expectEqual("afterbegin", newElement.className);
const fuzzWrapper = document.createElement("div");
fuzzWrapper.id = "fuzz-wrapper";
document.body.appendChild(fuzzWrapper);
const fuzzCases = [
// These cases have no <body> element (or empty body), so nothing is inserted
{ name: "empty string", html: "", expectElements: 0 },
{ name: "comment only", html: "<!-- comment -->", expectElements: 0 },
{ name: "doctype only", html: "<!DOCTYPE html>", expectElements: 0 },
{ name: "full empty doc", html: "<!DOCTYPE html><html><head></head><body></body></html>", expectElements: 0 },
{ name: "whitespace only", html: " ", expectElements: 0 },
{ name: "newlines only", html: "\n\n\n", expectElements: 0 },
{ name: "just text", html: "plain text", expectElements: 0 },
// Head-only elements: Extracted from <head> container
{ name: "empty meta", html: "<meta>", expectElements: 1 },
{ name: "empty title", html: "<title></title>", expectElements: 1 },
{ name: "empty head", html: "<head></head>", expectElements: 0 }, // Container with no children
{ name: "empty body", html: "<body></body>", expectElements: 0 }, // Container with no children
{ name: "empty html", html: "<html></html>", expectElements: 0 }, // Container with no children
{ name: "meta only", html: "<meta charset='utf-8'>", expectElements: 1 },
{ name: "title only", html: "<title>Test</title>", expectElements: 1 },
{ name: "link only", html: "<link rel='stylesheet' href='test.css'>", expectElements: 1 },
{ name: "meta and title", html: "<meta charset='utf-8'><title>Test</title>", expectElements: 2 },
{ name: "script only", html: "<script>console.log('hi')<\/script>", expectElements: 1 },
{ name: "style only", html: "<style>body { color: red; }<\/style>", expectElements: 1 },
{ name: "unclosed div", html: "<div>content", expectElements: 1 },
{ name: "unclosed span", html: "<span>text", expectElements: 1 },
{ name: "invalid tag", html: "<notarealtag>content</notarealtag>", expectElements: 1 },
{ name: "malformed", html: "<<div>>test<</div>>", expectElements: 1 }, // Parser handles it
{ name: "just closing tag", html: "</div>", expectElements: 0 },
{ name: "nested empty", html: "<div><div></div></div>", expectElements: 1 },
{ name: "multiple top-level", html: "<span>1</span><span>2</span><span>3</span>", expectElements: 3 },
{ name: "mixed text and elements", html: "Text before<b>bold</b>Text after", expectElements: 1 },
{ name: "deeply nested", html: "<div><div><div><span>deep</span></div></div></div>", expectElements: 1 },
];
fuzzCases.forEach((tc, idx) => {
fuzzWrapper.innerHTML = "";
fuzzWrapper.insertAdjacentHTML("beforeend", tc.html);
if (tc.expectElements !== fuzzWrapper.childElementCount) {
console.warn(`Fuzz idx: ${idx}`);
testing.expectEqual(tc.expectElements, fuzzWrapper.childElementCount);
}
});
// Clean up
document.body.removeChild(fuzzWrapper);
</script>

View File

@@ -23,6 +23,7 @@
<main>Main content</main>
<script id=byId name="test1">
testing.expectEqual(1, document.querySelector.length);
testing.expectError("SyntaxError: Syntax Error", () => document.querySelector(''));
testing.withError((err) => {
testing.expectEqual(12, err.code);

View File

@@ -248,7 +248,7 @@
}
</script>
<div id=legacy></a>
<a id=legacy></a>
<script id=legacy>
{
let a = document.getElementById('legacy').attributes;
@@ -266,3 +266,19 @@
testing.expectEqual('abc123', a[0].value);
}
</script>
<div id="nsa"></div>
<script id=non-string-attr>
{
let nsa = document.getElementById('nsa');
nsa.setAttribute('int', 1);
testing.expectEqual('1', nsa.getAttribute('int'));
nsa.setAttribute('obj', {});
testing.expectEqual('[object Object]', nsa.getAttribute('obj'));
nsa.setAttribute('arr', []);
testing.expectEqual('', nsa.getAttribute('arr'));
}
</script>

View File

@@ -0,0 +1,514 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<!-- Test inline event listeners set via HTML attributes -->
<div id="attr-click" onclick="window.x = 1"></div>
<div id="attr-load" onload="window.x = 1"></div>
<div id="attr-error" onerror="window.x = 1"></div>
<div id="attr-focus" onfocus="window.x = 1"></div>
<div id="attr-blur" onblur="window.x = 1"></div>
<div id="attr-keydown" onkeydown="window.x = 1"></div>
<div id="attr-mousedown" onmousedown="window.x = 1"></div>
<div id="attr-submit" onsubmit="window.x = 1"></div>
<div id="attr-wheel" onwheel="window.x = 1"></div>
<div id="attr-scroll" onscroll="window.x = 1"></div>
<div id="attr-contextmenu" oncontextmenu="window.x = 1"></div>
<div id="no-listeners"></div>
<script id="attr_listener_returns_function">
{
// Inline listeners set via HTML attributes should return a function
testing.expectEqual('function', typeof $('#attr-click').onclick);
testing.expectEqual('function', typeof $('#attr-load').onload);
testing.expectEqual('function', typeof $('#attr-error').onerror);
testing.expectEqual('function', typeof $('#attr-focus').onfocus);
testing.expectEqual('function', typeof $('#attr-blur').onblur);
testing.expectEqual('function', typeof $('#attr-keydown').onkeydown);
testing.expectEqual('function', typeof $('#attr-mousedown').onmousedown);
testing.expectEqual('function', typeof $('#attr-submit').onsubmit);
testing.expectEqual('function', typeof $('#attr-wheel').onwheel);
testing.expectEqual('function', typeof $('#attr-scroll').onscroll);
testing.expectEqual('function', typeof $('#attr-contextmenu').oncontextmenu);
}
</script>
<script id="no_attr_listener_returns_null">
{
// Elements without inline listeners should return null
const div = $('#no-listeners');
testing.expectEqual(null, div.onclick);
testing.expectEqual(null, div.onload);
testing.expectEqual(null, div.onerror);
testing.expectEqual(null, div.onfocus);
testing.expectEqual(null, div.onblur);
testing.expectEqual(null, div.onkeydown);
testing.expectEqual(null, div.onmousedown);
testing.expectEqual(null, div.onsubmit);
testing.expectEqual(null, div.onwheel);
testing.expectEqual(null, div.onscroll);
testing.expectEqual(null, div.oncontextmenu);
}
</script>
<script id="js_setter_getter">
{
// Test setting and getting listeners via JavaScript property
const div = document.createElement('div');
// Initially null
testing.expectEqual(null, div.onclick);
testing.expectEqual(null, div.onload);
testing.expectEqual(null, div.onerror);
// Set listeners
const clickHandler = () => {};
const loadHandler = () => {};
const errorHandler = () => {};
div.onclick = clickHandler;
div.onload = loadHandler;
div.onerror = errorHandler;
// Verify they can be retrieved and are functions
testing.expectEqual('function', typeof div.onclick);
testing.expectEqual('function', typeof div.onload);
testing.expectEqual('function', typeof div.onerror);
}
</script>
<script id="js_listener_invoke">
{
// Test that JS-set listeners can be invoked directly
const div = document.createElement('div');
window.jsInvokeResult = 0;
div.onclick = () => { window.jsInvokeResult = 100; };
div.onclick();
testing.expectEqual(100, window.jsInvokeResult);
div.onload = () => { window.jsInvokeResult = 200; };
div.onload();
testing.expectEqual(200, window.jsInvokeResult);
div.onfocus = () => { window.jsInvokeResult = 300; };
div.onfocus();
testing.expectEqual(300, window.jsInvokeResult);
}
</script>
<script id="js_listener_invoke_with_return">
{
// Test that JS-set listeners return values when invoked
const div = document.createElement('div');
div.onclick = () => { return 'click-result'; };
testing.expectEqual('click-result', div.onclick());
div.onload = () => { return 42; };
testing.expectEqual(42, div.onload());
div.onfocus = () => { return { key: 'value' }; };
testing.expectEqual('value', div.onfocus().key);
}
</script>
<script id="js_listener_invoke_with_args">
{
// Test that JS-set listeners can receive arguments when invoked
const div = document.createElement('div');
div.onclick = (a, b) => { return a + b; };
testing.expectEqual(15, div.onclick(10, 5));
div.onload = (msg) => { return 'Hello, ' + msg; };
testing.expectEqual('Hello, World', div.onload('World'));
}
</script>
<script id="js_setter_override">
{
// Test that setting a new listener overrides the old one
const div = document.createElement('div');
const first = () => { return 1; };
const second = () => { return 2; };
div.onclick = first;
testing.expectEqual('function', typeof div.onclick);
testing.expectEqual(1, div.onclick());
div.onclick = second;
testing.expectEqual('function', typeof div.onclick);
testing.expectEqual(2, div.onclick());
}
</script>
<script id="different_event_types_independent">
{
// Test that different event types are stored independently
const div = document.createElement('div');
const clickFn = () => {};
const focusFn = () => {};
const blurFn = () => {};
div.onclick = clickFn;
testing.expectEqual('function', typeof div.onclick);
testing.expectEqual(null, div.onfocus);
testing.expectEqual(null, div.onblur);
div.onfocus = focusFn;
testing.expectEqual('function', typeof div.onclick);
testing.expectEqual('function', typeof div.onfocus);
testing.expectEqual(null, div.onblur);
div.onblur = blurFn;
testing.expectEqual('function', typeof div.onclick);
testing.expectEqual('function', typeof div.onfocus);
testing.expectEqual('function', typeof div.onblur);
}
</script>
<script id="keyboard_event_listeners">
{
// Test keyboard event listener getters/setters
const div = document.createElement('div');
testing.expectEqual(null, div.onkeydown);
testing.expectEqual(null, div.onkeyup);
testing.expectEqual(null, div.onkeypress);
div.onkeydown = () => {};
div.onkeyup = () => {};
div.onkeypress = () => {};
testing.expectEqual('function', typeof div.onkeydown);
testing.expectEqual('function', typeof div.onkeyup);
testing.expectEqual('function', typeof div.onkeypress);
}
</script>
<script id="mouse_event_listeners">
{
// Test mouse event listener getters/setters
const div = document.createElement('div');
testing.expectEqual(null, div.onmousedown);
testing.expectEqual(null, div.onmouseup);
testing.expectEqual(null, div.onmousemove);
testing.expectEqual(null, div.onmouseover);
testing.expectEqual(null, div.onmouseout);
testing.expectEqual(null, div.ondblclick);
div.onmousedown = () => {};
div.onmouseup = () => {};
div.onmousemove = () => {};
div.onmouseover = () => {};
div.onmouseout = () => {};
div.ondblclick = () => {};
testing.expectEqual('function', typeof div.onmousedown);
testing.expectEqual('function', typeof div.onmouseup);
testing.expectEqual('function', typeof div.onmousemove);
testing.expectEqual('function', typeof div.onmouseover);
testing.expectEqual('function', typeof div.onmouseout);
testing.expectEqual('function', typeof div.ondblclick);
}
</script>
<script id="pointer_event_listeners">
{
// Test pointer event listener getters/setters
const div = document.createElement('div');
testing.expectEqual(null, div.onpointerdown);
testing.expectEqual(null, div.onpointerup);
testing.expectEqual(null, div.onpointermove);
testing.expectEqual(null, div.onpointerover);
testing.expectEqual(null, div.onpointerout);
testing.expectEqual(null, div.onpointerenter);
testing.expectEqual(null, div.onpointerleave);
testing.expectEqual(null, div.onpointercancel);
div.onpointerdown = () => {};
div.onpointerup = () => {};
div.onpointermove = () => {};
div.onpointerover = () => {};
div.onpointerout = () => {};
div.onpointerenter = () => {};
div.onpointerleave = () => {};
div.onpointercancel = () => {};
testing.expectEqual('function', typeof div.onpointerdown);
testing.expectEqual('function', typeof div.onpointerup);
testing.expectEqual('function', typeof div.onpointermove);
testing.expectEqual('function', typeof div.onpointerover);
testing.expectEqual('function', typeof div.onpointerout);
testing.expectEqual('function', typeof div.onpointerenter);
testing.expectEqual('function', typeof div.onpointerleave);
testing.expectEqual('function', typeof div.onpointercancel);
}
</script>
<script id="form_event_listeners">
{
// Test form event listener getters/setters
const form = document.createElement('form');
testing.expectEqual(null, form.onsubmit);
testing.expectEqual(null, form.onreset);
testing.expectEqual(null, form.onchange);
testing.expectEqual(null, form.oninput);
testing.expectEqual(null, form.oninvalid);
form.onsubmit = () => {};
form.onreset = () => {};
form.onchange = () => {};
form.oninput = () => {};
form.oninvalid = () => {};
testing.expectEqual('function', typeof form.onsubmit);
testing.expectEqual('function', typeof form.onreset);
testing.expectEqual('function', typeof form.onchange);
testing.expectEqual('function', typeof form.oninput);
testing.expectEqual('function', typeof form.oninvalid);
}
</script>
<script id="drag_event_listeners">
{
// Test drag event listener getters/setters
const div = document.createElement('div');
testing.expectEqual(null, div.ondrag);
testing.expectEqual(null, div.ondragstart);
testing.expectEqual(null, div.ondragend);
testing.expectEqual(null, div.ondragenter);
testing.expectEqual(null, div.ondragleave);
testing.expectEqual(null, div.ondragover);
testing.expectEqual(null, div.ondrop);
div.ondrag = () => {};
div.ondragstart = () => {};
div.ondragend = () => {};
div.ondragenter = () => {};
div.ondragleave = () => {};
div.ondragover = () => {};
div.ondrop = () => {};
testing.expectEqual('function', typeof div.ondrag);
testing.expectEqual('function', typeof div.ondragstart);
testing.expectEqual('function', typeof div.ondragend);
testing.expectEqual('function', typeof div.ondragenter);
testing.expectEqual('function', typeof div.ondragleave);
testing.expectEqual('function', typeof div.ondragover);
testing.expectEqual('function', typeof div.ondrop);
}
</script>
<script id="clipboard_event_listeners">
{
// Test clipboard event listener getters/setters
const div = document.createElement('div');
testing.expectEqual(null, div.oncopy);
testing.expectEqual(null, div.oncut);
testing.expectEqual(null, div.onpaste);
div.oncopy = () => {};
div.oncut = () => {};
div.onpaste = () => {};
testing.expectEqual('function', typeof div.oncopy);
testing.expectEqual('function', typeof div.oncut);
testing.expectEqual('function', typeof div.onpaste);
}
</script>
<script id="scroll_event_listeners">
{
// Test scroll event listener getters/setters
const div = document.createElement('div');
testing.expectEqual(null, div.onscroll);
testing.expectEqual(null, div.onscrollend);
testing.expectEqual(null, div.onresize);
div.onscroll = () => {};
div.onscrollend = () => {};
div.onresize = () => {};
testing.expectEqual('function', typeof div.onscroll);
testing.expectEqual('function', typeof div.onscrollend);
testing.expectEqual('function', typeof div.onresize);
}
</script>
<script id="animation_event_listeners">
{
// Test animation event listener getters/setters
const div = document.createElement('div');
testing.expectEqual(null, div.onanimationstart);
testing.expectEqual(null, div.onanimationend);
testing.expectEqual(null, div.onanimationiteration);
testing.expectEqual(null, div.onanimationcancel);
div.onanimationstart = () => {};
div.onanimationend = () => {};
div.onanimationiteration = () => {};
div.onanimationcancel = () => {};
testing.expectEqual('function', typeof div.onanimationstart);
testing.expectEqual('function', typeof div.onanimationend);
testing.expectEqual('function', typeof div.onanimationiteration);
testing.expectEqual('function', typeof div.onanimationcancel);
}
</script>
<script id="transition_event_listeners">
{
// Test transition event listener getters/setters
const div = document.createElement('div');
testing.expectEqual(null, div.ontransitionstart);
testing.expectEqual(null, div.ontransitionend);
testing.expectEqual(null, div.ontransitionrun);
testing.expectEqual(null, div.ontransitioncancel);
div.ontransitionstart = () => {};
div.ontransitionend = () => {};
div.ontransitionrun = () => {};
div.ontransitioncancel = () => {};
testing.expectEqual('function', typeof div.ontransitionstart);
testing.expectEqual('function', typeof div.ontransitionend);
testing.expectEqual('function', typeof div.ontransitionrun);
testing.expectEqual('function', typeof div.ontransitioncancel);
}
</script>
<script id="misc_event_listeners">
{
// Test miscellaneous event listener getters/setters
const div = document.createElement('div');
testing.expectEqual(null, div.onwheel);
testing.expectEqual(null, div.ontoggle);
testing.expectEqual(null, div.oncontextmenu);
testing.expectEqual(null, div.onselect);
testing.expectEqual(null, div.onabort);
testing.expectEqual(null, div.oncancel);
testing.expectEqual(null, div.onclose);
div.onwheel = () => {};
div.ontoggle = () => {};
div.oncontextmenu = () => {};
div.onselect = () => {};
div.onabort = () => {};
div.oncancel = () => {};
div.onclose = () => {};
testing.expectEqual('function', typeof div.onwheel);
testing.expectEqual('function', typeof div.ontoggle);
testing.expectEqual('function', typeof div.oncontextmenu);
testing.expectEqual('function', typeof div.onselect);
testing.expectEqual('function', typeof div.onabort);
testing.expectEqual('function', typeof div.oncancel);
testing.expectEqual('function', typeof div.onclose);
}
</script>
<script id="media_event_listeners">
{
// Test media event listener getters/setters
const div = document.createElement('div');
testing.expectEqual(null, div.onplay);
testing.expectEqual(null, div.onpause);
testing.expectEqual(null, div.onplaying);
testing.expectEqual(null, div.onended);
testing.expectEqual(null, div.onvolumechange);
testing.expectEqual(null, div.onwaiting);
testing.expectEqual(null, div.onseeking);
testing.expectEqual(null, div.onseeked);
testing.expectEqual(null, div.ontimeupdate);
testing.expectEqual(null, div.onloadstart);
testing.expectEqual(null, div.onprogress);
testing.expectEqual(null, div.onstalled);
testing.expectEqual(null, div.onsuspend);
testing.expectEqual(null, div.oncanplay);
testing.expectEqual(null, div.oncanplaythrough);
testing.expectEqual(null, div.ondurationchange);
testing.expectEqual(null, div.onemptied);
testing.expectEqual(null, div.onloadeddata);
testing.expectEqual(null, div.onloadedmetadata);
testing.expectEqual(null, div.onratechange);
div.onplay = () => {};
div.onpause = () => {};
div.onplaying = () => {};
div.onended = () => {};
div.onvolumechange = () => {};
div.onwaiting = () => {};
div.onseeking = () => {};
div.onseeked = () => {};
div.ontimeupdate = () => {};
div.onloadstart = () => {};
div.onprogress = () => {};
div.onstalled = () => {};
div.onsuspend = () => {};
div.oncanplay = () => {};
div.oncanplaythrough = () => {};
div.ondurationchange = () => {};
div.onemptied = () => {};
div.onloadeddata = () => {};
div.onloadedmetadata = () => {};
div.onratechange = () => {};
testing.expectEqual('function', typeof div.onplay);
testing.expectEqual('function', typeof div.onpause);
testing.expectEqual('function', typeof div.onplaying);
testing.expectEqual('function', typeof div.onended);
testing.expectEqual('function', typeof div.onvolumechange);
testing.expectEqual('function', typeof div.onwaiting);
testing.expectEqual('function', typeof div.onseeking);
testing.expectEqual('function', typeof div.onseeked);
testing.expectEqual('function', typeof div.ontimeupdate);
testing.expectEqual('function', typeof div.onloadstart);
testing.expectEqual('function', typeof div.onprogress);
testing.expectEqual('function', typeof div.onstalled);
testing.expectEqual('function', typeof div.onsuspend);
testing.expectEqual('function', typeof div.oncanplay);
testing.expectEqual('function', typeof div.oncanplaythrough);
testing.expectEqual('function', typeof div.ondurationchange);
testing.expectEqual('function', typeof div.onemptied);
testing.expectEqual('function', typeof div.onloadeddata);
testing.expectEqual('function', typeof div.onloadedmetadata);
testing.expectEqual('function', typeof div.onratechange);
}
</script>
<img src="https://cdn.lightpanda.io/website/assets/images/docs/hn.png" />
<script id="document-element-load">
{
let asyncBlockDispatched = false;
const docElement = document.documentElement;
testing.async(async () => {
const result = await new Promise(resolve => {
// We should get this fired at capturing phase when a resource loaded.
docElement.addEventListener("load", e => {
testing.expectEqual(e.eventPhase, Event.CAPTURING_PHASE);
return resolve(true);
}, true);
});
asyncBlockDispatched = true;
testing.expectEqual(true, result);
});
testing.eventually(() => testing.expectEqual(true, asyncBlockDispatched));
}
</script>

View File

@@ -97,3 +97,62 @@
testing.expectEqual('lazy', img.getAttribute('loading'));
}
</script>
<script id="load-trigger-event">
{
const img = document.createElement("img");
let count = 0;
img.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(true, count < 3);
count++;
testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble);
testing.expectEqual(false, cancelable);
testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted);
testing.expectEqual(img, target);
});
for (let i = 0; i < 3; i++) {
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
testing.expectEqual("https://cdn.lightpanda.io/website/assets/images/docs/hn.png", img.src);
}
// Make sure count is incremented asynchronously.
testing.expectEqual(0, count);
}
</script>
<img
id="inline-img"
src="https://cdn.lightpanda.io/website/assets/images/docs/hn.png"
onload="(() => testing.expectEqual(true, true))()"
/>
<script id="inline-on-load">
{
const img = document.getElementById("inline-img");
testing.expectEqual(true, img.onload instanceof Function);
// Also call inline to double check.
img.onload();
// Make sure ones attached with `addEventListener` also executed.
testing.async(async () => {
const result = await new Promise(resolve => {
img.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble);
testing.expectEqual(false, cancelable);
testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted);
testing.expectEqual(img, target);
return resolve(true);
});
});
testing.expectEqual(true, result);
});
}
</script>

View File

@@ -183,7 +183,45 @@
}
</script>
<script id="defaultChecked">
<script id="selectionchange_event">
{
const input = document.createElement('input');
input.value = 'Hello World';
document.body.appendChild(input);
let eventCount = 0;
let lastEvent = null;
input.addEventListener('selectionchange', (e) => {
eventCount++;
lastEvent = e;
});
testing.expectEqual(0, eventCount);
input.setSelectionRange(0, 5);
input.select();
input.selectionStart = 3;
input.selectionEnd = 8;
let bubbledToBody = false;
document.body.addEventListener('selectionchange', () => {
bubbledToBody = true;
});
input.setSelectionRange(1, 4);
testing.eventually(() => {
testing.expectEqual(5, eventCount);
testing.expectEqual('selectionchange', lastEvent.type);
testing.expectEqual(input, lastEvent.target);
testing.expectEqual(true, lastEvent.bubbles);
testing.expectEqual(false, lastEvent.cancelable);
testing.expectEqual(true, bubbledToBody);
});
}
</script>
<!-- <script id="defaultChecked">
testing.expectEqual(true, $('#check1').defaultChecked)
testing.expectEqual(false, $('#check2').defaultChecked)
testing.expectEqual(true, $('#radio1').defaultChecked)
@@ -455,4 +493,4 @@
input_checked.defaultChecked = true;
testing.expectEqual(false, input_checked.checked);
}
</script>
</script> -->

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<!-- <script id="createElement">
{
const picture = document.createElement('picture');
testing.expectEqual('PICTURE', picture.tagName);
testing.expectEqual('[object HTMLPictureElement]', Object.prototype.toString.call(picture));
}
</script>
<script id="constructor_type">
{
const picture = document.createElement('picture');
testing.expectEqual(true, picture instanceof HTMLElement);
testing.expectEqual(true, picture instanceof Element);
testing.expectEqual(true, picture instanceof Node);
}
</script> -->
<picture id="inline-picture">
<source media="(min-width: 800px)" srcset="large.jpg">
<source media="(min-width: 400px)" srcset="medium.jpg">
<img src="small.jpg" alt="Test image">
</picture>
<script id="inline_picture">
{
const picture = document.getElementById('inline-picture');
testing.expectEqual('PICTURE', picture.tagName);
testing.expectEqual(3, picture.children.length);
const sources = picture.querySelectorAll('source');
testing.expectEqual(2, sources.length);
// const img = picture.querySelector('img');
// testing.expectEqual('IMG', img.tagName);
}
</script>
<!-- <script id="appendChild">
{
const picture = document.createElement('picture');
const source = document.createElement('source');
const img = document.createElement('img');
picture.appendChild(source);
picture.appendChild(img);
testing.expectEqual(2, picture.children.length);
testing.expectEqual('SOURCE', picture.children[0].tagName);
testing.expectEqual('IMG', picture.children[1].tagName);
}
</script>
-->

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<script src="../../../testing.js"></script>
<script id="script">
{
let s = document.createElement('script');
testing.expectEqual('', s.src);
s.src = '/over.9000.js';
testing.expectEqual('http://127.0.0.1:9582/over.9000.js', s.src);
}
</script>

View File

@@ -229,3 +229,41 @@
testing.expectEqual('some content', clone.value)
}
</script>
<script id="selectionchange_event">
{
const textarea = document.createElement('textarea');
textarea.value = 'Hello World';
document.body.appendChild(textarea);
let eventCount = 0;
let lastEvent = null;
textarea.addEventListener('selectionchange', (e) => {
eventCount++;
lastEvent = e;
});
testing.expectEqual(0, eventCount);
textarea.setSelectionRange(0, 5);
textarea.select();
textarea.selectionStart = 3;
textarea.selectionEnd = 8;
let bubbledToBody = false;
document.body.addEventListener('selectionchange', () => {
bubbledToBody = true;
});
textarea.setSelectionRange(1, 4);
testing.eventually(() => {
testing.expectEqual(5, eventCount);
testing.expectEqual('selectionchange', lastEvent.type);
testing.expectEqual(textarea, lastEvent.target);
testing.expectEqual(true, lastEvent.bubbles);
testing.expectEqual(false, lastEvent.cancelable);
testing.expectEqual(true, bubbledToBody);
});
}
</script>

View File

@@ -0,0 +1,334 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<!-- Test 1: Basic single element replacement -->
<div id="test1">
<div id="parent1">
<div id="old1">Old Content</div>
</div>
</div>
<script id="test1-basic-replacement">
const old1 = $('#old1');
const parent1 = $('#parent1');
testing.expectEqual(1, parent1.childElementCount);
testing.expectEqual(old1, document.getElementById('old1'));
const new1 = document.createElement('div');
new1.id = 'new1';
new1.textContent = 'New Content';
old1.replaceWith(new1);
testing.expectEqual(1, parent1.childElementCount);
testing.expectEqual(null, document.getElementById('old1'));
testing.expectEqual(new1, document.getElementById('new1'));
testing.expectEqual(parent1, new1.parentElement);
</script>
<!-- Test 2: Replace with multiple elements -->
<div id="test2">
<div id="parent2">
<div id="old2">Old</div>
</div>
</div>
<script id="test2-multiple-elements">
const old2 = $('#old2');
const parent2 = $('#parent2');
testing.expectEqual(1, parent2.childElementCount);
const new2a = document.createElement('div');
new2a.id = 'new2a';
const new2b = document.createElement('div');
new2b.id = 'new2b';
const new2c = document.createElement('div');
new2c.id = 'new2c';
old2.replaceWith(new2a, new2b, new2c);
testing.expectEqual(3, parent2.childElementCount);
testing.expectEqual(null, document.getElementById('old2'));
testing.expectEqual(new2a, document.getElementById('new2a'));
testing.expectEqual(new2b, document.getElementById('new2b'));
testing.expectEqual(new2c, document.getElementById('new2c'));
// Check order
testing.expectEqual(new2a, parent2.children[0]);
testing.expectEqual(new2b, parent2.children[1]);
testing.expectEqual(new2c, parent2.children[2]);
</script>
<!-- Test 3: Replace with text nodes -->
<div id="test3">
<div id="parent3"><div id="old3">Old</div></div>
</div>
<script id="test3-text-nodes">
const old3 = $('#old3');
const parent3 = $('#parent3');
old3.replaceWith('Text1', ' ', 'Text2');
testing.expectEqual(null, document.getElementById('old3'));
testing.expectEqual('Text1 Text2', parent3.textContent);
</script>
<!-- Test 4: Replace with mix of elements and text -->
<div id="test4">
<div id="parent4"><div id="old4">Old</div></div>
</div>
<script id="test4-mixed">
const old4 = $('#old4');
const parent4 = $('#parent4');
const new4 = document.createElement('span');
new4.id = 'new4';
new4.textContent = 'Element';
old4.replaceWith('Before ', new4, ' After');
testing.expectEqual(null, document.getElementById('old4'));
testing.expectEqual(new4, document.getElementById('new4'));
testing.expectEqual('Before Element After', parent4.textContent);
</script>
<!-- Test 5: Replace element not connected to document -->
<script id="test5-not-connected">
const disconnected = document.createElement('div');
disconnected.id = 'disconnected5';
const replacement = document.createElement('div');
replacement.id = 'replacement5';
// Should do nothing since element has no parent
disconnected.replaceWith(replacement);
// Neither should be in the document
testing.expectEqual(null, document.getElementById('disconnected5'));
testing.expectEqual(null, document.getElementById('replacement5'));
</script>
<!-- Test 6: Replace with nodes that already have a parent -->
<div id="test6">
<div id="parent6a">
<div id="old6">Old</div>
</div>
<div id="parent6b">
<div id="moving6a">Moving A</div>
<div id="moving6b">Moving B</div>
</div>
</div>
<script id="test6-moving-nodes">
const old6 = $('#old6');
const parent6a = $('#parent6a');
const parent6b = $('#parent6b');
const moving6a = $('#moving6a');
const moving6b = $('#moving6b');
testing.expectEqual(1, parent6a.childElementCount);
testing.expectEqual(2, parent6b.childElementCount);
// Replace old6 with nodes that already have parent6b as parent
old6.replaceWith(moving6a, moving6b);
// old6 should be gone
testing.expectEqual(null, document.getElementById('old6'));
// parent6a should now have the moved elements
testing.expectEqual(2, parent6a.childElementCount);
testing.expectEqual(moving6a, parent6a.children[0]);
testing.expectEqual(moving6b, parent6a.children[1]);
// parent6b should now be empty
testing.expectEqual(0, parent6b.childElementCount);
// getElementById should still work
testing.expectEqual(moving6a, document.getElementById('moving6a'));
testing.expectEqual(moving6b, document.getElementById('moving6b'));
testing.expectEqual(parent6a, moving6a.parentElement);
testing.expectEqual(parent6a, moving6b.parentElement);
</script>
<!-- Test 7: Replace with nested elements -->
<div id="test7">
<div id="parent7">
<div id="old7">Old</div>
</div>
</div>
<script id="test7-nested">
const old7 = $('#old7');
const parent7 = $('#parent7');
const new7 = document.createElement('div');
new7.id = 'new7';
const child7a = document.createElement('div');
child7a.id = 'child7a';
const child7b = document.createElement('div');
child7b.id = 'child7b';
new7.appendChild(child7a);
new7.appendChild(child7b);
old7.replaceWith(new7);
testing.expectEqual(null, document.getElementById('old7'));
testing.expectEqual(new7, document.getElementById('new7'));
testing.expectEqual(child7a, document.getElementById('child7a'));
testing.expectEqual(child7b, document.getElementById('child7b'));
testing.expectEqual(2, new7.childElementCount);
</script>
<!-- Test 8: Replace maintains sibling order -->
<div id="test8">
<div id="parent8">
<div id="before8">Before</div>
<div id="old8">Old</div>
<div id="after8">After</div>
</div>
</div>
<script id="test8-sibling-order">
const old8 = $('#old8');
const parent8 = $('#parent8');
const before8 = $('#before8');
const after8 = $('#after8');
testing.expectEqual(3, parent8.childElementCount);
const new8 = document.createElement('div');
new8.id = 'new8';
old8.replaceWith(new8);
testing.expectEqual(3, parent8.childElementCount);
testing.expectEqual(before8, parent8.children[0]);
testing.expectEqual(new8, parent8.children[1]);
testing.expectEqual(after8, parent8.children[2]);
</script>
<!-- Test 9: Replace first child -->
<div id="test9">
<div id="parent9">
<div id="first9">First</div>
<div id="second9">Second</div>
</div>
</div>
<script id="test9-first-child">
const first9 = $('#first9');
const parent9 = $('#parent9');
const new9 = document.createElement('div');
new9.id = 'new9';
first9.replaceWith(new9);
testing.expectEqual(null, document.getElementById('first9'));
testing.expectEqual(new9, parent9.firstElementChild);
testing.expectEqual(new9, parent9.children[0]);
</script>
<!-- Test 10: Replace last child -->
<div id="test10">
<div id="parent10">
<div id="first10">First</div>
<div id="last10">Last</div>
</div>
</div>
<script id="test10-last-child">
const last10 = $('#last10');
const parent10 = $('#parent10');
const new10 = document.createElement('div');
new10.id = 'new10';
last10.replaceWith(new10);
testing.expectEqual(null, document.getElementById('last10'));
testing.expectEqual(new10, parent10.lastElementChild);
testing.expectEqual(new10, parent10.children[1]);
</script>
<!-- Test 11: Replace with empty (no arguments) - effectively removes the element -->
<div id="test11">
<div id="parent11">
<div id="old11">Old</div>
</div>
</div>
<script id="test11-empty">
const old11 = $('#old11');
const parent11 = $('#parent11');
testing.expectEqual(1, parent11.childElementCount);
// Calling replaceWith() with no args should just remove the element
old11.replaceWith();
// Element should be removed, leaving parent empty
testing.expectEqual(0, parent11.childElementCount);
testing.expectEqual(null, document.getElementById('old11'));
testing.expectEqual(null, old11.parentElement);
</script>
<!-- Test 12: Replace and check childElementCount updates -->
<div id="test12">
<div id="parent12">
<div id="a12">A</div>
<div id="b12">B</div>
<div id="c12">C</div>
</div>
</div>
<script id="test12-child-count">
const b12 = $('#b12');
const parent12 = $('#parent12');
testing.expectEqual(3, parent12.childElementCount);
// Replace with 2 elements
const new12a = document.createElement('div');
new12a.id = 'new12a';
const new12b = document.createElement('div');
new12b.id = 'new12b';
b12.replaceWith(new12a, new12b);
testing.expectEqual(4, parent12.childElementCount);
testing.expectEqual(null, document.getElementById('b12'));
</script>
<!-- Test 13: Replace deeply nested element -->
<div id="test13">
<div id="l1">
<div id="l2">
<div id="l3">
<div id="l4">
<div id="old13">Deep</div>
</div>
</div>
</div>
</div>
</div>
<script id="test13-deeply-nested">
const old13 = $('#old13');
const l4 = $('#l4');
const new13 = document.createElement('div');
new13.id = 'new13';
old13.replaceWith(new13);
testing.expectEqual(null, document.getElementById('old13'));
testing.expectEqual(new13, document.getElementById('new13'));
testing.expectEqual(l4, new13.parentElement);
</script>

View File

@@ -0,0 +1,165 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=default>
{
let event = new PointerEvent('pointerdown');
testing.expectEqual('pointerdown', event.type);
testing.expectEqual(true, event instanceof PointerEvent);
testing.expectEqual(true, event instanceof MouseEvent);
testing.expectEqual(true, event instanceof UIEvent);
testing.expectEqual(true, event instanceof Event);
testing.expectEqual(0, event.pointerId);
testing.expectEqual('', event.pointerType);
testing.expectEqual(1.0, event.width);
testing.expectEqual(1.0, event.height);
testing.expectEqual(0.0, event.pressure);
testing.expectEqual(0.0, event.tangentialPressure);
testing.expectEqual(0, event.tiltX);
testing.expectEqual(0, event.tiltY);
testing.expectEqual(0, event.twist);
testing.expectEqual(false, event.isPrimary);
}
</script>
<script id=parameters>
{
let new_event = new PointerEvent('pointerdown', {
pointerId: 42,
pointerType: 'pen',
width: 10.5,
height: 20.5,
pressure: 0.75,
tangentialPressure: -0.25,
tiltX: 30,
tiltY: 45,
twist: 90,
isPrimary: true,
clientX: 100,
clientY: 200,
screenX: 300,
screenY: 400
});
testing.expectEqual(42, new_event.pointerId);
testing.expectEqual('pen', new_event.pointerType);
testing.expectEqual(10.5, new_event.width);
testing.expectEqual(20.5, new_event.height);
testing.expectEqual(0.75, new_event.pressure);
testing.expectEqual(-0.25, new_event.tangentialPressure);
testing.expectEqual(30, new_event.tiltX);
testing.expectEqual(45, new_event.tiltY);
testing.expectEqual(90, new_event.twist);
testing.expectEqual(true, new_event.isPrimary);
testing.expectEqual(100, new_event.clientX);
testing.expectEqual(200, new_event.clientY);
testing.expectEqual(300, new_event.screenX);
testing.expectEqual(400, new_event.screenY);
}
</script>
<script id=mousePointerType>
{
let mouse_event = new PointerEvent('pointerdown', { pointerType: 'mouse' });
testing.expectEqual('mouse', mouse_event.pointerType);
}
</script>
<script id=touchPointerType>
{
let touch_event = new PointerEvent('pointerdown', { pointerType: 'touch', pointerId: 1, pressure: 0.5 });
testing.expectEqual('touch', touch_event.pointerType);
testing.expectEqual(1, touch_event.pointerId);
testing.expectEqual(0.5, touch_event.pressure);
}
</script>
<script id=listener>
{
let pe = new PointerEvent('pointerdown', { pointerId: 123 });
testing.expectEqual(true, pe instanceof PointerEvent);
testing.expectEqual(true, pe instanceof MouseEvent);
testing.expectEqual(true, pe instanceof Event);
var evt = null;
document.addEventListener('pointerdown', function (e) {
evt = e;
});
document.dispatchEvent(pe);
testing.expectEqual('pointerdown', evt.type);
testing.expectEqual(true, evt instanceof PointerEvent);
testing.expectEqual(123, evt.pointerId);
}
</script>
<script id=isTrusted>
{
let pointerEvent = new PointerEvent('pointerup');
testing.expectEqual(false, pointerEvent.isTrusted);
let pointerIsTrusted = null;
document.addEventListener('pointertest', (e) => {
pointerIsTrusted = e.isTrusted;
testing.expectEqual(true, e instanceof PointerEvent);
});
document.dispatchEvent(new PointerEvent('pointertest'));
testing.expectEqual(false, pointerIsTrusted);
}
</script>
<script id=eventTypes>
{
let down = new PointerEvent('pointerdown');
testing.expectEqual('pointerdown', down.type);
let up = new PointerEvent('pointerup');
testing.expectEqual('pointerup', up.type);
let move = new PointerEvent('pointermove');
testing.expectEqual('pointermove', move.type);
let enter = new PointerEvent('pointerenter');
testing.expectEqual('pointerenter', enter.type);
let leave = new PointerEvent('pointerleave');
testing.expectEqual('pointerleave', leave.type);
let over = new PointerEvent('pointerover');
testing.expectEqual('pointerover', over.type);
let out = new PointerEvent('pointerout');
testing.expectEqual('pointerout', out.type);
let cancel = new PointerEvent('pointercancel');
testing.expectEqual('pointercancel', cancel.type);
}
</script>
<script id=inheritedMouseProperties>
{
let pe = new PointerEvent('pointerdown', {
button: 2,
buttons: 4,
altKey: true,
ctrlKey: true,
shiftKey: true,
metaKey: true
});
testing.expectEqual(2, pe.button);
testing.expectEqual(true, pe.altKey);
testing.expectEqual(true, pe.ctrlKey);
testing.expectEqual(true, pe.shiftKey);
testing.expectEqual(true, pe.metaKey);
}
</script>
<script id=inheritedUIEventProperties>
{
let pe = new PointerEvent('pointerdown', {
detail: 5,
bubbles: true,
cancelable: true
});
testing.expectEqual(5, pe.detail);
testing.expectEqual(true, pe.bubbles);
testing.expectEqual(true, pe.cancelable);
}
</script>

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=project_rejection>
{
let e1 = new PromiseRejectionEvent("rejectionhandled");
testing.expectEqual(true, e1 instanceof PromiseRejectionEvent);
testing.expectEqual(true, e1 instanceof Event);
testing.expectEqual("rejectionhandled", e1.type);
testing.expectEqual(null, e1.reason);
testing.expectEqual(null, e1.promise);
let e2 = new PromiseRejectionEvent("rejectionhandled", {reason: ['tea']});
testing.expectEqual(true, e2 instanceof PromiseRejectionEvent);
testing.expectEqual(true, e2 instanceof Event);
testing.expectEqual("rejectionhandled", e2.type);
testing.expectEqual(['tea'], e2.reason);
testing.expectEqual(null, e2.promise);
}
</script>

View File

@@ -30,7 +30,7 @@
testing.eventually(() => {
testing.expectEqual(true, popstateEventFired);
testing.expectEqual(state, popstateEventState);
testing.expectEqual({testInProgress: true }, popstateEventState);
})
history.back();

View File

@@ -1,6 +1 @@
<!DOCTYPE html>
<script src="testing.js"></script>
<script id=history-after-nav>
testing.expectEqual(true, history.state && history.state.testInProgress);
</script>

View File

@@ -106,3 +106,17 @@
testing.expectEqual(req5, target);
})
</script>
<script id=xhr6 type=module>
const req5 = new XMLHttpRequest()
const promise5 = new Promise((resolve) => {
req5.onload = resolve;
req5.open('PROPFIND', 'http://127.0.0.1:9589/xhr')
req5.send('foo')
});
testing.async(promise5, () => {
testing.expectEqual(200, req5.status);
testing.expectEqual('OK', req5.statusText);
testing.expectEqual(true, req5.responseText.length > 65);
});

View File

@@ -130,3 +130,10 @@
testing.expectEqual("you", request2.headers.get("target"));
}
</script>
<script id=propfind>
{
const req = new Request('https://example.com/api', { method: 'propfind' });
testing.expectEqual('PROPFIND', req.method);
}
</script>

View File

@@ -125,6 +125,26 @@
})
</script>
<script id=xhr6>
const req6 = new XMLHttpRequest()
testing.async(async (restore) => {
await new Promise((resolve) => {
req6.onload = resolve;
req6.open('GET', 'http://127.0.0.1:9582/xhr/binary')
req6.responseType ='arraybuffer'
req6.send()
});
restore();
testing.expectEqual(200, req6.status);
testing.expectEqual('OK', req6.statusText);
testing.expectEqual(7, req6.response.byteLength);
testing.expectEqual([0, 0, 1, 2, 0, 0, 9], new Int32Array(req6.response));
testing.expectEqual('', typeof req6.response);
testing.expectEqual('arraybuffer', req6.responseType);
});
</script>
<script id=xhr_redirect>
testing.async(async (restore) => {
const req = new XMLHttpRequest();

View File

@@ -58,3 +58,6 @@
testing.expectEqual(true, e.toString().includes("FailedToLoad"), {script_id: 'import-404'});
}
</script>
<!-- this used to crash -->
<script type=module src=modules/self_async.js></script>

View File

@@ -0,0 +1 @@
const c = await import('./self_async.js');

View File

@@ -0,0 +1,595 @@
<!DOCTYPE html>
<meta charset="UTF-8">
<script src="./testing.js"></script>
<div id="test-content">
<p id="p1">The quick brown fox</p>
<p id="p2">jumps over the lazy dog</p>
<div id="nested">
<span id="s1">Hello</span>
<span id="s2">World</span>
</div>
</div>
<script id=basic>
{
const sel = window.getSelection();
sel.removeAllRanges();
testing.expectEqual(0, sel.rangeCount);
testing.expectEqual("None", sel.type);
testing.expectEqual(true, sel.isCollapsed);
testing.expectEqual(null, sel.anchorNode);
testing.expectEqual(null, sel.focusNode);
testing.expectEqual(0, sel.anchorOffset);
testing.expectEqual(0, sel.focusOffset);
testing.expectEqual("none", sel.direction);
}
</script>
<script id=collapse>
{
const sel = window.getSelection();
sel.removeAllRanges();
const p1 = document.getElementById("p1");
const textNode = p1.firstChild;
// Collapse to a position
sel.collapse(textNode, 4);
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual("Caret", sel.type);
testing.expectEqual(true, sel.isCollapsed);
testing.expectEqual(textNode, sel.anchorNode);
testing.expectEqual(textNode, sel.focusNode);
testing.expectEqual(4, sel.anchorOffset);
testing.expectEqual(4, sel.focusOffset);
testing.expectEqual("none", sel.direction);
// Collapse to null removes all ranges
sel.collapse(null);
testing.expectEqual(0, sel.rangeCount);
testing.expectEqual("None", sel.type);
}
</script>
<script id=setPosition>
{
const sel = window.getSelection();
sel.removeAllRanges();
const p2 = document.getElementById("p2");
const textNode = p2.firstChild;
// setPosition is an alias for collapse
sel.setPosition(textNode, 10);
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual("Caret", sel.type);
testing.expectEqual(textNode, sel.anchorNode);
testing.expectEqual(10, sel.anchorOffset);
// Test default offset
sel.setPosition(textNode);
testing.expectEqual(0, sel.anchorOffset);
// Test null
sel.setPosition(null);
testing.expectEqual(0, sel.rangeCount);
}
</script>
<script id=addRange>
{
const sel = window.getSelection();
sel.removeAllRanges();
const range1 = document.createRange();
const p1 = document.getElementById("p1");
range1.selectNodeContents(p1);
sel.addRange(range1);
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual("Range", sel.type);
testing.expectEqual(false, sel.isCollapsed);
// Adding same range again should do nothing
sel.addRange(range1);
testing.expectEqual(1, sel.rangeCount);
// Adding different range
const range2 = document.createRange();
const p2 = document.getElementById("p2");
range2.selectNodeContents(p2);
sel.addRange(range2);
// Firefox does support multiple ranges so it will be 2 here instead of 1.
// Chrome and Safari don't so we don't either.
testing.expectEqual(1, sel.rangeCount);
}
</script>
<script id=getRangeAt>
{
const sel = window.getSelection();
sel.removeAllRanges();
const range = document.createRange();
const p1 = document.getElementById("p1");
range.selectNodeContents(p1);
sel.addRange(range);
const retrieved = sel.getRangeAt(0);
testing.expectEqual(range, retrieved);
}
</script>
<script id=removeRange>
{
const sel = window.getSelection();
sel.removeAllRanges();
const range1 = document.createRange();
const range2 = document.createRange();
const p1 = document.getElementById("p1");
const p2 = document.getElementById("p2");
range1.selectNodeContents(p1);
range2.selectNodeContents(p2);
sel.addRange(range1);
sel.addRange(range2);
// Firefox does support multiple ranges so it will be 2 here instead of 1.
// Chrome and Safari don't so we don't either.
testing.expectEqual(1, sel.rangeCount);
// Chrome doesn't throw an error here even though the spec defines it:
// https://w3c.github.io/selection-api/#dom-selection-removerange
testing.expectError('NotFoundError', () => { sel.removeRange(range2); });
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual(range1, sel.getRangeAt(0));
}
</script>
<script id=removeAllRanges>
{
const sel = window.getSelection();
sel.removeAllRanges();
const range1 = document.createRange();
const range2 = document.createRange();
range1.selectNodeContents(document.getElementById("p1"));
range2.selectNodeContents(document.getElementById("p2"));
sel.addRange(range1);
sel.addRange(range2);
// Firefox does support multiple ranges so it will be 2 here instead of 1.
// Chrome and Safari don't so we don't either.
testing.expectEqual(1, sel.rangeCount);
sel.removeAllRanges();
testing.expectEqual(0, sel.rangeCount);
testing.expectEqual("none", sel.direction);
}
</script>
<script id=empty>
{
const sel = window.getSelection();
sel.removeAllRanges();
const range = document.createRange();
range.selectNodeContents(document.getElementById("p1"));
sel.addRange(range);
testing.expectEqual(1, sel.rangeCount);
// empty() is an alias for removeAllRanges()
sel.empty();
testing.expectEqual(0, sel.rangeCount);
}
</script>
<script id=collapseToStart>
{
const sel = window.getSelection();
const p1 = document.getElementById("p1");
const textNode = p1.firstChild;
const range = document.createRange();
range.setStart(textNode, 4);
range.setEnd(textNode, 15);
sel.removeAllRanges();
sel.addRange(range);
testing.expectEqual(false, sel.isCollapsed);
testing.expectEqual(4, sel.anchorOffset);
testing.expectEqual(15, sel.focusOffset);
sel.collapseToStart();
testing.expectEqual(true, sel.isCollapsed);
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual(textNode, sel.anchorNode);
testing.expectEqual(4, sel.anchorOffset);
testing.expectEqual(4, sel.focusOffset);
testing.expectEqual("none", sel.direction);
}
</script>
<script id=collapseToEnd>
{
const sel = window.getSelection();
const p1 = document.getElementById("p1");
const textNode = p1.firstChild;
const range = document.createRange();
range.setStart(textNode, 4);
range.setEnd(textNode, 15);
sel.removeAllRanges();
sel.addRange(range);
testing.expectEqual(false, sel.isCollapsed);
sel.collapseToEnd();
testing.expectEqual(true, sel.isCollapsed);
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual(textNode, sel.anchorNode);
testing.expectEqual(15, sel.anchorOffset);
testing.expectEqual(15, sel.focusOffset);
testing.expectEqual("none", sel.direction);
}
</script>
<script id=extend>
{
const sel = window.getSelection();
const p1 = document.getElementById("p1");
const textNode = p1.firstChild;
// Start with collapsed selection
sel.collapse(textNode, 10);
testing.expectEqual(true, sel.isCollapsed);
testing.expectEqual(10, sel.anchorOffset);
testing.expectEqual("none", sel.direction);
// Extend forward
sel.extend(textNode, 15);
testing.expectEqual(false, sel.isCollapsed);
testing.expectEqual(10, sel.anchorOffset);
testing.expectEqual(15, sel.focusOffset);
testing.expectEqual("forward", sel.direction);
// Extend backward from anchor
sel.extend(textNode, 5);
testing.expectEqual(false, sel.isCollapsed);
testing.expectEqual(10, sel.anchorOffset);
testing.expectEqual(5, sel.focusOffset);
testing.expectEqual("backward", sel.direction);
// Extend to same position as anchor
sel.extend(textNode, 10);
testing.expectEqual(true, sel.isCollapsed);
testing.expectEqual(10, sel.anchorOffset);
testing.expectEqual(10, sel.focusOffset);
testing.expectEqual("none", sel.direction);
}
</script>
<script id=direction>
{
const sel = window.getSelection();
const p1 = document.getElementById("p1");
const textNode = p1.firstChild;
// Forward selection
sel.collapse(textNode, 5);
sel.extend(textNode, 10);
testing.expectEqual("forward", sel.direction);
testing.expectEqual(5, sel.anchorOffset);
testing.expectEqual(10, sel.focusOffset);
// Backward selection
sel.collapse(textNode, 10);
sel.extend(textNode, 5);
testing.expectEqual("backward", sel.direction);
testing.expectEqual(10, sel.anchorOffset);
testing.expectEqual(5, sel.focusOffset);
// None (collapsed)
sel.collapse(textNode, 7);
testing.expectEqual("none", sel.direction);
}
</script>
<script id=containsNode>
{
const sel = window.getSelection();
const nested = document.getElementById("nested");
const s1 = document.getElementById("s1");
const s2 = document.getElementById("s2");
const range = document.createRange();
range.selectNodeContents(nested);
sel.removeAllRanges();
sel.addRange(range);
// Partial containment
testing.expectEqual(true, sel.containsNode(s1, true));
testing.expectEqual(true, sel.containsNode(s2, true));
testing.expectEqual(true, sel.containsNode(nested, true));
// Node outside selection
const p1 = document.getElementById("p1");
testing.expectEqual(false, sel.containsNode(p1, false));
testing.expectEqual(false, sel.containsNode(p1, true));
}
</script>
<script id=deleteFromDocument>
{
const sel = window.getSelection();
sel.removeAllRanges();
const p1 = document.getElementById("p1");
const textNode = p1.firstChild;
const originalText = textNode.textContent;
const range = document.createRange();
range.setStart(textNode, 4);
range.setEnd(textNode, 15);
sel.removeAllRanges();
sel.addRange(range);
sel.deleteFromDocument();
// Text should be deleted
const expectedText = originalText.slice(0, 4) + originalText.slice(15);
testing.expectEqual(expectedText, textNode.textContent);
// Selection should be collapsed at deletion point
testing.expectEqual(true, sel.isCollapsed);
// Restore original text for other tests
textNode.textContent = originalText;
}
</script>
<script id=typeProperty>
{
const sel = window.getSelection();
const p1 = document.getElementById("p1");
const textNode = p1.firstChild;
// None type
sel.removeAllRanges();
testing.expectEqual("None", sel.type);
// Caret type (collapsed)
sel.collapse(textNode, 5);
testing.expectEqual("Caret", sel.type);
// Range type (not collapsed)
sel.extend(textNode, 10);
testing.expectEqual("Range", sel.type);
}
</script>
<script id=selectAllChildren>
{
const sel = window.getSelection();
sel.removeAllRanges();
const nested = document.getElementById("nested");
const s1 = document.getElementById("s1");
const s2 = document.getElementById("s2");
// Select all children of nested div
sel.selectAllChildren(nested);
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual("Range", sel.type);
testing.expectEqual(false, sel.isCollapsed);
// Anchor and focus should be on the parent node
testing.expectEqual(nested, sel.anchorNode);
testing.expectEqual(nested, sel.focusNode);
// Should start at offset 0 (before first child)
testing.expectEqual(0, sel.anchorOffset);
const childrenCount = nested.childNodes.length;
// Should end at offset equal to number of children (after last child)
testing.expectEqual(childrenCount, sel.focusOffset);
// Direction should be forward
testing.expectEqual("forward", sel.direction);
// Should not fully contain the parent itself
testing.expectEqual(false, sel.containsNode(nested, false));
// But should partially contain the parent
testing.expectEqual(true, sel.containsNode(nested, true));
// Verify the range
const range = sel.getRangeAt(0);
testing.expectEqual(nested, range.startContainer);
testing.expectEqual(nested, range.endContainer);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(childrenCount, range.endOffset);
}
</script>
<script id=selectAllChildrenEmpty>
{
const sel = window.getSelection();
sel.removeAllRanges();
// Create an empty element
const empty = document.createElement("div");
document.body.appendChild(empty);
// Select all children of empty element
sel.selectAllChildren(empty);
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual("Caret", sel.type); // Collapsed because no children
testing.expectEqual(true, sel.isCollapsed);
testing.expectEqual(empty, sel.anchorNode);
testing.expectEqual(0, sel.anchorOffset);
testing.expectEqual(0, sel.focusOffset);
// Clean up
document.body.removeChild(empty);
}
</script>
<script id=selectAllChildrenReplacesSelection>
{
const sel = window.getSelection();
sel.removeAllRanges();
// Start with an existing selection
const p1 = document.getElementById("p1");
sel.selectAllChildren(p1);
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual(p1, sel.anchorNode);
// selectAllChildren should replace the existing selection
const p2 = document.getElementById("p2");
sel.selectAllChildren(p2);
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual(p2, sel.anchorNode);
testing.expectEqual(p2, sel.focusNode);
// Verify old selection is gone
const range = sel.getRangeAt(0);
testing.expectEqual(p2, range.startContainer);
testing.expectEqual(false, p1 == range.startContainer);
}
</script>
<script id=setBaseAndExtent>
{
const sel = window.getSelection();
sel.removeAllRanges();
const p1 = document.getElementById("p1");
const textNode = p1.firstChild;
// Forward selection (anchor before focus)
sel.setBaseAndExtent(textNode, 4, textNode, 15);
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual("Range", sel.type);
testing.expectEqual(false, sel.isCollapsed);
testing.expectEqual(textNode, sel.anchorNode);
testing.expectEqual(4, sel.anchorOffset);
testing.expectEqual(textNode, sel.focusNode);
testing.expectEqual(15, sel.focusOffset);
testing.expectEqual("forward", sel.direction);
// Backward selection (anchor after focus)
sel.setBaseAndExtent(textNode, 15, textNode, 4);
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual("Range", sel.type);
testing.expectEqual(textNode, sel.anchorNode);
testing.expectEqual(15, sel.anchorOffset);
testing.expectEqual(textNode, sel.focusNode);
testing.expectEqual(4, sel.focusOffset);
testing.expectEqual("backward", sel.direction);
// Collapsed selection (anchor equals focus)
sel.setBaseAndExtent(textNode, 10, textNode, 10);
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual("Caret", sel.type);
testing.expectEqual(true, sel.isCollapsed);
testing.expectEqual(10, sel.anchorOffset);
testing.expectEqual(10, sel.focusOffset);
testing.expectEqual("none", sel.direction);
// Across different nodes
const p2 = document.getElementById("p2");
const textNode2 = p2.firstChild;
sel.setBaseAndExtent(textNode, 4, textNode2, 5);
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual(textNode, sel.anchorNode);
testing.expectEqual(4, sel.anchorOffset);
testing.expectEqual(textNode2, sel.focusNode);
testing.expectEqual(5, sel.focusOffset);
testing.expectEqual("forward", sel.direction);
// Should replace existing selection
sel.setBaseAndExtent(textNode, 0, textNode, 3);
testing.expectEqual(1, sel.rangeCount);
testing.expectEqual(0, sel.anchorOffset);
testing.expectEqual(3, sel.focusOffset);
}
</script>
<script id=selectionChangeEvent>
{
const sel = window.getSelection();
sel.removeAllRanges();
let eventCount = 0;
let lastEvent = null;
document.addEventListener('selectionchange', (e) => {
eventCount++;
lastEvent = e;
});
const p1 = document.getElementById("p1");
const textNode = p1.firstChild;
const nested = document.getElementById("nested");
sel.collapse(textNode, 5);
sel.extend(textNode, 10);
sel.collapseToStart();
sel.collapseToEnd();
sel.removeAllRanges();
const range = document.createRange();
range.setStart(textNode, 4);
range.setEnd(textNode, 15);
sel.addRange(range);
sel.removeRange(range);
const newRange = document.createRange();
newRange.selectNodeContents(p1);
sel.addRange(newRange);
sel.removeAllRanges();
sel.selectAllChildren(nested);
sel.setBaseAndExtent(textNode, 4, textNode, 15);
sel.collapse(textNode, 5);
sel.extend(textNode, 10);
sel.deleteFromDocument();
testing.eventually(() => {
testing.expectEqual(14, eventCount);
testing.expectEqual('selectionchange', lastEvent.type);
testing.expectEqual(document, lastEvent.target);
testing.expectEqual(false, lastEvent.bubbles);
testing.expectEqual(false, lastEvent.cancelable);
});
}
</script>

View File

@@ -0,0 +1,211 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=onerrorBasicCallback>
{
let callbackCalled = false;
let receivedArgs = null;
window.onerror = function(message, source, lineno, colno, error) {
callbackCalled = true;
receivedArgs = { message, source, lineno, colno, error };
};
const err = new Error('Test error');
window.reportError(err);
testing.expectEqual(true, callbackCalled);
testing.expectEqual(true, receivedArgs.message.includes('Test error'));
testing.expectEqual(err, receivedArgs.error);
window.onerror = null;
}
</script>
<script id=onerrorFiveArguments>
{
let argCount = 0;
window.onerror = function() {
argCount = arguments.length;
};
window.reportError(new Error('Five args test'));
// Per WHATWG spec, onerror receives exactly 5 arguments
testing.expectEqual(5, argCount);
window.onerror = null;
}
</script>
<script id=onerrorReturnTrueCancelsEvent>
{
let listenerCalled = false;
window.onerror = function() {
return true; // Should cancel the event
};
const listener = function() {
listenerCalled = true;
};
window.addEventListener('error', listener);
window.reportError(new Error('Should be cancelled'));
// The event listener should still be called (onerror returning true
// only prevents default, not propagation)
testing.expectEqual(true, listenerCalled);
window.onerror = null;
window.removeEventListener('error', listener);
}
</script>
<script id=onerrorAndEventListenerBothCalled>
{
let onerrorCalled = false;
let listenerCalled = false;
window.onerror = function() {
onerrorCalled = true;
};
const listener = function() {
listenerCalled = true;
};
window.addEventListener('error', listener);
window.reportError(new Error('Both should fire'));
testing.expectEqual(true, onerrorCalled);
testing.expectEqual(true, listenerCalled);
window.onerror = null;
window.removeEventListener('error', listener);
}
</script>
<script id=onerrorCalledBeforeEventListener>
{
let callOrder = [];
window.onerror = function() {
callOrder.push('onerror');
};
const listener = function() {
callOrder.push('listener');
};
window.addEventListener('error', listener);
window.reportError(new Error('Order test'));
// onerror should be called before addEventListener handlers
testing.expectEqual('onerror', callOrder[0]);
testing.expectEqual('listener', callOrder[1]);
window.onerror = null;
window.removeEventListener('error', listener);
}
</script>
<script id=onerrorGetterSetter>
{
const handler = function() {};
testing.expectEqual(null, window.onerror);
window.onerror = handler;
testing.expectEqual(handler, window.onerror);
window.onerror = null;
testing.expectEqual(null, window.onerror);
}
</script>
<script id=onerrorWithNonFunction>
{
// Setting onerror to a non-function should not throw
// but should not be stored as the handler
window.onerror = "not a function";
testing.expectEqual(null, window.onerror);
window.onerror = {};
testing.expectEqual(null, window.onerror);
window.onerror = 123;
testing.expectEqual(null, window.onerror);
}
</script>
<script id=onerrorArgumentTypes>
{
let receivedTypes = null;
window.onerror = function(message, source, lineno, colno, error) {
receivedTypes = {
message: typeof message,
source: typeof source,
lineno: typeof lineno,
colno: typeof colno,
error: typeof error
};
};
window.reportError(new Error('Type check'));
testing.expectEqual('string', receivedTypes.message);
testing.expectEqual('string', receivedTypes.source);
testing.expectEqual('number', receivedTypes.lineno);
testing.expectEqual('number', receivedTypes.colno);
testing.expectEqual('object', receivedTypes.error);
window.onerror = null;
}
</script>
<script id=onerrorReturnFalseDoesNotCancel>
{
let eventDefaultPrevented = false;
window.onerror = function() {
return false; // Should NOT cancel the event
};
const listener = function(e) {
eventDefaultPrevented = e.defaultPrevented;
};
window.addEventListener('error', listener);
window.reportError(new Error('Return false test'));
testing.expectEqual(false, eventDefaultPrevented);
window.onerror = null;
window.removeEventListener('error', listener);
}
</script>
<script id=onerrorReturnTruePreventsDefault>
{
let eventDefaultPrevented = false;
window.onerror = function() {
return true; // Should cancel (prevent default)
};
const listener = function(e) {
eventDefaultPrevented = e.defaultPrevented;
};
window.addEventListener('error', listener);
window.reportError(new Error('Return true test'));
testing.expectEqual(true, eventDefaultPrevented);
window.onerror = null;
window.removeEventListener('error', listener);
}
</script>

View File

@@ -25,6 +25,7 @@
<script id=setTimeout>
testing.expectEqual(1, window.setTimeout.length);
let wst2 = 1;
window.setTimeout((a, b) => {
wst2 = a + b;

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=visual_viewport>
const vp = window.visualViewport;
testing.expectEqual(vp, window.visualViewport);
testing.expectEqual(0, vp.offsetLeft);
testing.expectEqual(0, vp.offsetTop);
testing.expectEqual(0, vp.pageLeft);
testing.expectEqual(0, vp.pageTop);
testing.expectEqual(1920, vp.width);
testing.expectEqual(1080, vp.height);
testing.expectEqual(1.0, vp.scale);
</script>

View File

@@ -5,6 +5,7 @@
testing.expectEqual(window, globalThis);
testing.expectEqual(window, self);
testing.expectEqual(window, window.self);
testing.expectEqual(null, window.opener);
testing.expectEqual(1080, innerHeight);
testing.expectEqual(1920, innerWidth);
@@ -51,6 +52,7 @@
<script id=btoa>
testing.expectEqual('SGVsbG8gV29ybGQh', btoa('Hello World!'));
testing.expectEqual('', btoa(''));
testing.expectEqual('IA==', btoa(' '));
testing.expectEqual('YQ==', btoa('a'));
testing.expectEqual('YWI=', btoa('ab'));
testing.expectEqual('YWJj', btoa('abc'));
@@ -61,6 +63,13 @@
<script id=atob>
testing.expectEqual('Hello World!', atob('SGVsbG8gV29ybGQh'));
testing.expectEqual('', atob(''));
// atob must trim input
testing.expectEqual('', atob(' '));
testing.expectEqual(' ', atob('IA=='));
testing.expectEqual(' ', atob(' IA=='));
testing.expectEqual(' ', atob('IA== '));
testing.expectEqual('a', atob('YQ=='));
testing.expectEqual('ab', atob('YWI='));
testing.expectEqual('abc', atob('YWJj'));
@@ -105,3 +114,30 @@
testing.expectEqual(24, screen.pixelDepth);
testing.expectEqual(screen, window.screen);
</script>
<script id=unhandled_rejection>
{
let unhandledCalled = 0;
window.onunhandledrejection = function(e) {
testing.expectEqual(true, e instanceof PromiseRejectionEvent);
testing.expectEqual({x: 'Fail'}, e.reason);
testing.expectEqual('unhandledrejection', e.type);
testing.expectEqual(window, e.target);
testing.expectEqual(window, e.srcElement);
testing.expectEqual(window, e.currentTarget);
unhandledCalled += 1;
}
window.addEventListener('unhandledrejection', function(e) {
testing.expectEqual(true, e instanceof PromiseRejectionEvent);
testing.expectEqual({x: 'Fail'}, e.reason);
testing.expectEqual('unhandledrejection', e.type);
testing.expectEqual(window, e.target);
testing.expectEqual(window, e.srcElement);
testing.expectEqual(window, e.currentTarget);
unhandledCalled += 1;
});
Promise.reject({x: 'Fail'});
testing.eventually(() => testing.expectEqual(2, unhandledCalled));
}
</script>

View File

@@ -38,7 +38,7 @@ pub fn getSignal(self: *const AbortController) *AbortSignal {
}
pub fn abort(self: *AbortController, reason_: ?js.Value.Global, page: *Page) !void {
try self._signal.abort(if (reason_) |r| .{ .js_val = r } else null, page);
try self._signal.abort(if (reason_) |r| .{ .js_val = r } else null, page.js.local.?, page);
}
pub const JsApi = struct {

View File

@@ -57,7 +57,7 @@ pub fn asEventTarget(self: *AbortSignal) *EventTarget {
return self._proto;
}
pub fn abort(self: *AbortSignal, reason_: ?Reason, page: *Page) !void {
pub fn abort(self: *AbortSignal, reason_: ?Reason, local: *const js.Local, page: *Page) !void {
if (self._aborted) {
return;
}
@@ -76,12 +76,13 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, page: *Page) !void {
}
// Dispatch abort event
const event = try Event.initTrusted("abort", .{}, page);
const func = if (self._on_abort) |*g| g.local() else null;
const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page);
defer if (!event._v8_handoff) event.deinit(false);
try page._event_manager.dispatchWithFunction(
self.asEventTarget(),
event,
func,
local.toLocal(self._on_abort),
.{ .context = "abort signal" },
);
}
@@ -89,7 +90,7 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, page: *Page) !void {
// Static method to create an already-aborted signal
pub fn createAborted(reason_: ?js.Value.Global, page: *Page) !*AbortSignal {
const signal = try init(page);
try signal.abort(if (reason_) |r| .{ .js_val = r } else null, page);
try signal.abort(if (reason_) |r| .{ .js_val = r } else null, page.js.local.?, page);
return signal;
}
@@ -100,7 +101,7 @@ pub fn createTimeout(delay: u32, page: *Page) !*AbortSignal {
.signal = try init(page),
};
try page.scheduler.add(callback, TimeoutCallback.run, delay, .{
try page.js.scheduler.add(callback, TimeoutCallback.run, delay, .{
.name = "AbortSignal.timeout",
});
@@ -112,11 +113,13 @@ const ThrowIfAborted = union(enum) {
undefined: void,
};
pub fn throwIfAborted(self: *const AbortSignal, page: *Page) !ThrowIfAborted {
const local = page.js.local.?;
if (self._aborted) {
const exception = switch (self._reason) {
.string => |str| page.js.throw(str),
.js_val => |js_val| page.js.throw(try js_val.local().toString(.{ .allocator = page.call_arena })),
.undefined => page.js.throw("AbortError"),
.string => |str| local.throw(str),
.js_val => |js_val| local.throw(try local.toLocal(js_val).toStringSlice()),
.undefined => local.throw("AbortError"),
};
return .{ .exception = exception };
}
@@ -135,7 +138,11 @@ const TimeoutCallback = struct {
fn run(ctx: *anyopaque) !?u32 {
const self: *TimeoutCallback = @ptrCast(@alignCast(ctx));
self.signal.abort(.{ .string = "TimeoutError" }, self.page) catch |err| {
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
self.signal.abort(.{ .string = "TimeoutError" }, &ls.local, self.page) catch |err| {
log.warn(.app, "abort signal timeout", .{ .err = err });
};
return null;

View File

@@ -206,7 +206,7 @@ fn writeBlobParts(
/// Returns a Promise that resolves with the contents of the blob
/// as binary data contained in an ArrayBuffer.
pub fn arrayBuffer(self: *const Blob, page: *Page) !js.Promise {
return page.js.resolvePromise(js.ArrayBuffer{ .values = self._slice });
return page.js.local.?.resolvePromise(js.ArrayBuffer{ .values = self._slice });
}
const ReadableStream = @import("streams/ReadableStream.zig");
@@ -219,7 +219,7 @@ pub fn stream(self: *const Blob, page: *Page) !*ReadableStream {
/// Returns a Promise that resolves with a string containing
/// the contents of the blob, interpreted as UTF-8.
pub fn text(self: *const Blob, page: *Page) !js.Promise {
return page.js.resolvePromise(self._slice);
return page.js.local.?.resolvePromise(self._slice);
}
/// Extension to Blob; works on Firefox and Safari.
@@ -227,7 +227,7 @@ pub fn text(self: *const Blob, page: *Page) !js.Promise {
/// Returns a Promise that resolves with a Uint8Array containing
/// the contents of the blob as an array of bytes.
pub fn bytes(self: *const Blob, page: *Page) !js.Promise {
return page.js.resolvePromise(js.TypedArray(u8){ .values = self._slice });
return page.js.local.?.resolvePromise(js.TypedArray(u8){ .values = self._slice });
}
/// Returns a new Blob object which contains data

View File

@@ -42,15 +42,23 @@ pub fn parseDimension(value: []const u8) ?f64 {
/// https://drafts.csswg.org/cssom/#the-css.escape()-method
pub fn escape(_: *const CSS, value: []const u8, page: *Page) ![]const u8 {
if (value.len == 0) {
return error.InvalidCharacterError;
return "";
}
const first = value[0];
if (first == '-' and value.len == 1) {
return "\\-";
}
// Count how many characters we need for the output
var out_len: usize = escapeLen(true, first);
for (value[1..]) |c| {
out_len += escapeLen(false, c);
for (value[1..], 0..) |c, i| {
// Second char (i==0) is a digit and first is '-', needs hex escape
if (i == 0 and first == '-' and c >= '0' and c <= '9') {
out_len += 2 + hexDigitsNeeded(c);
} else {
out_len += escapeLen(false, c);
}
}
if (out_len == value.len) {
@@ -67,8 +75,13 @@ pub fn escape(_: *const CSS, value: []const u8, page: *Page) ![]const u8 {
pos = 1;
}
for (value[1..]) |c| {
if (!needsEscape(false, c)) {
for (value[1..], 0..) |c, i| {
// Second char (i==0) is a digit and first is '-', needs hex escape
if (i == 0 and first == '-' and c >= '0' and c <= '9') {
result[pos] = '\\';
const hex_str = std.fmt.bufPrint(result[pos + 1 ..], "{x} ", .{c}) catch unreachable;
pos += 1 + hex_str.len;
} else if (!needsEscape(false, c)) {
result[pos] = c;
pos += 1;
} else {
@@ -105,9 +118,6 @@ fn needsEscape(comptime is_first: bool, c: u8) bool {
if (c >= '0' and c <= '9') {
return true;
}
if (c == '-') {
return true;
}
}
// Characters that need escaping

View File

@@ -31,7 +31,7 @@ pub const init: Console = .{};
pub fn trace(_: *const Console, values: []js.Value, page: *Page) !void {
logger.debug(.js, "console.trace", .{
.stack = page.js.stackTrace() catch "???",
.stack = page.js.local.?.stackTrace() catch "???",
.args = ValueWriter{ .page = page, .values = values },
});
}
@@ -65,6 +65,10 @@ pub fn @"error"(_: *const Console, values: []js.Value, page: *Page) void {
logger.warn(.js, "console.error", .{ValueWriter{ .page = page, .values = values, .include_stack = true }});
}
pub fn table(_: *const Console, data: js.Value, columns: ?js.Value) void {
logger.info(.js, "console.table", .{ .data = data, .columns = columns });
}
pub fn count(self: *Console, label_: ?[]const u8, page: *Page) !void {
const label = label_ orelse "default";
const gop = try self._counts.getOrPut(page.arena, label);
@@ -138,7 +142,7 @@ const ValueWriter = struct {
try writer.print("\n arg({d}): {f}", .{ i, value });
}
if (self.include_stack) {
try writer.print("\n stack: {s}", .{self.page.js.stackTrace() catch |err| @errorName(err) orelse "???"});
try writer.print("\n stack: {s}", .{self.page.js.local.?.stackTrace() catch |err| @errorName(err) orelse "???"});
}
}
@@ -146,7 +150,7 @@ const ValueWriter = struct {
var buf: [32]u8 = undefined;
for (self.values, 0..) |value, i| {
const name = try std.fmt.bufPrint(&buf, "param.{d}", .{i});
try writer.write(name, try value.toString(.{}));
try writer.write(name, value);
}
}
@@ -167,7 +171,6 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const trace = bridge.function(Console.trace, .{});
@@ -179,9 +182,15 @@ pub const JsApi = struct {
pub const assert = bridge.function(Console.assert, .{});
pub const @"error" = bridge.function(Console.@"error", .{});
pub const exception = bridge.function(Console.@"error", .{});
pub const table = bridge.function(Console.table, .{});
pub const count = bridge.function(Console.count, .{});
pub const countReset = bridge.function(Console.countReset, .{});
pub const time = bridge.function(Console.time, .{});
pub const timeLog = bridge.function(Console.timeLog, .{});
pub const timeEnd = bridge.function(Console.timeEnd, .{});
};
const testing = @import("../../testing.zig");
test "WebApi: Console" {
try testing.htmlRunner("console", .{});
}

View File

@@ -19,8 +19,6 @@
const std = @import("std");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const SubtleCrypto = @import("SubtleCrypto.zig");
const Crypto = @This();

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -17,21 +17,25 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const String = @import("../../string.zig").String;
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Element = @import("Element.zig");
const CustomElementDefinition = @This();
name: []const u8,
constructor: js.Function.Global,
// TODO: Make this a Map<String>
observed_attributes: std.StringHashMapUnmanaged(void) = .{},
// For customized built-in elements, this is the element tag they extend (e.g., .button)
// For autonomous custom elements, this is null
extends: ?Element.Tag = null,
pub fn isAttributeObserved(self: *const CustomElementDefinition, name: []const u8) bool {
return self.observed_attributes.contains(name);
pub fn isAttributeObserved(self: *const CustomElementDefinition, name: String) bool {
return self.observed_attributes.contains(name.str());
}
pub fn isAutonomous(self: *const CustomElementDefinition) bool {

View File

@@ -73,9 +73,8 @@ pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Fu
var js_arr = observed_attrs.toArray();
for (0..js_arr.len()) |i| {
const attr_val = js_arr.get(@intCast(i)) catch continue;
const attr_name = attr_val.toString(.{ .allocator = page.arena }) catch continue;
const owned_attr = page.dupeString(attr_name) catch continue;
definition.observed_attributes.put(page.arena, owned_attr, {}) catch continue;
const attr_name = attr_val.toStringSliceWithAlloc(page.arena) catch continue;
definition.observed_attributes.put(page.arena, attr_name, {}) catch continue;
}
}
}
@@ -106,7 +105,7 @@ pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Fu
}
if (self._when_defined.fetchRemove(name)) |entry| {
entry.value.local().resolve("whenDefined", constructor);
page.js.toLocal(entry.value).resolve("whenDefined", constructor);
}
}
@@ -120,22 +119,23 @@ pub fn upgrade(self: *CustomElementRegistry, root: *Node, page: *Page) !void {
}
pub fn whenDefined(self: *CustomElementRegistry, name: []const u8, page: *Page) !js.Promise {
const local = page.js.local.?;
if (self._definitions.get(name)) |definition| {
return page.js.resolvePromise(definition.constructor);
return local.resolvePromise(definition.constructor);
}
const gop = try self._when_defined.getOrPut(page.arena, name);
if (gop.found_existing) {
return gop.value_ptr.local().promise();
return local.toLocal(gop.value_ptr.*).promise();
}
errdefer _ = self._when_defined.remove(name);
const owned_name = try page.dupeString(name);
const resolver = try page.js.createPromiseResolver().persist();
const resolver = local.createPromiseResolver();
gop.key_ptr.* = owned_name;
gop.value_ptr.* = resolver;
gop.value_ptr.* = try resolver.persist();
return resolver.local().promise();
return resolver.promise();
}
fn upgradeNode(self: *CustomElementRegistry, node: *Node, page: *Page) !void {
@@ -174,8 +174,12 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio
page._upgrading_element = node;
defer page._upgrading_element = prev_upgrading;
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
var caught: js.TryCatch.Caught = undefined;
_ = definition.constructor.local().newInstance(&caught) catch |err| {
_ = ls.toLocal(definition.constructor).newInstance(&caught) catch |err| {
log.warn(.js, "custom element upgrade", .{ .name = definition.name, .err = err, .caught = caught });
return error.CustomElementUpgradeFailed;
};
@@ -183,9 +187,9 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio
// Invoke attributeChangedCallback for existing observed attributes
var attr_it = custom.asElement().attributeIterator();
while (attr_it.next()) |attr| {
const name = attr._name.str();
const name = attr._name;
if (definition.isAttributeObserved(name)) {
custom.invokeAttributeChangedCallback(name, null, attr._value.str(), page);
custom.invokeAttributeChangedCallback(name, null, attr._value, page);
}
}

View File

@@ -31,6 +31,7 @@ _what_to_show: u32,
_filter: NodeFilter,
_reference_node: *Node,
_pointer_before_reference_node: bool,
_active: bool = false,
pub fn init(root: *Node, what_to_show: u32, filter: ?FilterOpts, page: *Page) !*DOMNodeIterator {
const node_filter = try NodeFilter.init(filter);
@@ -63,14 +64,21 @@ pub fn getFilter(self: *const DOMNodeIterator) ?FilterOpts {
return self._filter._original_filter;
}
pub fn nextNode(self: *DOMNodeIterator) !?*Node {
pub fn nextNode(self: *DOMNodeIterator, page: *Page) !?*Node {
if (self._active) {
return error.InvalidStateError;
}
self._active = true;
defer self._active = false;
var node = self._reference_node;
var before_node = self._pointer_before_reference_node;
while (true) {
if (before_node) {
before_node = false;
const result = try self.filterNode(node);
const result = try self.filterNode(node, page);
if (result == NodeFilter.FILTER_ACCEPT) {
self._reference_node = node;
self._pointer_before_reference_node = false;
@@ -84,7 +92,7 @@ pub fn nextNode(self: *DOMNodeIterator) !?*Node {
}
node = next.?;
const result = try self.filterNode(node);
const result = try self.filterNode(node, page);
if (result == NodeFilter.FILTER_ACCEPT) {
self._reference_node = node;
self._pointer_before_reference_node = false;
@@ -94,13 +102,20 @@ pub fn nextNode(self: *DOMNodeIterator) !?*Node {
}
}
pub fn previousNode(self: *DOMNodeIterator) !?*Node {
pub fn previousNode(self: *DOMNodeIterator, page: *Page) !?*Node {
if (self._active) {
return error.InvalidStateError;
}
self._active = true;
defer self._active = false;
var node = self._reference_node;
var before_node = self._pointer_before_reference_node;
while (true) {
if (!before_node) {
const result = try self.filterNode(node);
const result = try self.filterNode(node, page);
if (result == NodeFilter.FILTER_ACCEPT) {
self._reference_node = node;
self._pointer_before_reference_node = true;
@@ -119,7 +134,11 @@ pub fn previousNode(self: *DOMNodeIterator) !?*Node {
}
}
fn filterNode(self: *const DOMNodeIterator, node: *Node) !i32 {
pub fn detach(_: *const DOMNodeIterator) void {
// no-op legacy
}
fn filterNode(self: *const DOMNodeIterator, node: *Node, page: *Page) !i32 {
// First check whatToShow
if (!NodeFilter.shouldShow(node, self._what_to_show)) {
return NodeFilter.FILTER_SKIP;
@@ -128,7 +147,7 @@ fn filterNode(self: *const DOMNodeIterator, node: *Node) !i32 {
// Then check the filter callback
// For NodeIterator, REJECT and SKIP are equivalent - both skip the node
// but continue with its descendants
const result = try self._filter.acceptNode(node);
const result = try self._filter.acceptNode(node, page.js.local.?);
return result;
}
@@ -181,6 +200,7 @@ pub const JsApi = struct {
pub const whatToShow = bridge.accessor(DOMNodeIterator.getWhatToShow, null, .{});
pub const filter = bridge.accessor(DOMNodeIterator.getFilter, null, .{});
pub const nextNode = bridge.function(DOMNodeIterator.nextNode, .{});
pub const previousNode = bridge.function(DOMNodeIterator.previousNode, .{});
pub const nextNode = bridge.function(DOMNodeIterator.nextNode, .{ .dom_exception = true });
pub const previousNode = bridge.function(DOMNodeIterator.previousNode, .{ .dom_exception = true });
pub const detach = bridge.function(DOMNodeIterator.detach, .{});
};

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -26,7 +26,6 @@ const Parser = @import("../parser/Parser.zig");
const HTMLDocument = @import("HTMLDocument.zig");
const XMLDocument = @import("XMLDocument.zig");
const Document = @import("Document.zig");
const ProcessingInstruction = @import("../webapi/cdata/ProcessingInstruction.zig");
const DOMParser = @This();
@@ -48,6 +47,9 @@ pub fn parseFromString(
@"image/svg+xml",
}, mime_type) orelse return error.NotSupported;
const arena = try page.getArena(.{ .debug = "DOMParser.parseFromString" });
defer page.releaseArena(arena);
return switch (target_mime) {
.@"text/html" => {
// Create a new HTMLDocument
@@ -61,7 +63,7 @@ pub fn parseFromString(
}
// Parse HTML into the document
var parser = Parser.init(page.arena, doc.asNode(), page);
var parser = Parser.init(arena, doc.asNode(), page);
parser.parse(normalized);
if (parser.err) |pe| {
@@ -78,7 +80,7 @@ pub fn parseFromString(
// Parse XML into XMLDocument.
const doc_node = doc.asNode();
var parser = Parser.init(page.arena, doc_node, page);
var parser = Parser.init(arena, doc_node, page);
parser.parseXML(html);
if (parser.err) |pe| {

View File

@@ -62,13 +62,13 @@ pub fn setCurrentNode(self: *DOMTreeWalker, node: *Node) void {
}
// Navigation methods
pub fn parentNode(self: *DOMTreeWalker) !?*Node {
pub fn parentNode(self: *DOMTreeWalker, page: *Page) !?*Node {
var node = self._current._parent;
while (node) |n| {
if (n == self._root._parent) {
return null;
}
if (try self.acceptNode(n) == NodeFilter.FILTER_ACCEPT) {
if (try self.acceptNode(n, page) == NodeFilter.FILTER_ACCEPT) {
self._current = n;
return n;
}
@@ -77,11 +77,11 @@ pub fn parentNode(self: *DOMTreeWalker) !?*Node {
return null;
}
pub fn firstChild(self: *DOMTreeWalker) !?*Node {
pub fn firstChild(self: *DOMTreeWalker, page: *Page) !?*Node {
var node = self._current.firstChild();
while (node) |n| {
const filter_result = try self.acceptNode(n);
const filter_result = try self.acceptNode(n, page);
if (filter_result == NodeFilter.FILTER_ACCEPT) {
self._current = n;
@@ -117,11 +117,11 @@ pub fn firstChild(self: *DOMTreeWalker) !?*Node {
return null;
}
pub fn lastChild(self: *DOMTreeWalker) !?*Node {
pub fn lastChild(self: *DOMTreeWalker, page: *Page) !?*Node {
var node = self._current.lastChild();
while (node) |n| {
const filter_result = try self.acceptNode(n);
const filter_result = try self.acceptNode(n, page);
if (filter_result == NodeFilter.FILTER_ACCEPT) {
self._current = n;
@@ -157,10 +157,10 @@ pub fn lastChild(self: *DOMTreeWalker) !?*Node {
return null;
}
pub fn previousSibling(self: *DOMTreeWalker) !?*Node {
pub fn previousSibling(self: *DOMTreeWalker, page: *Page) !?*Node {
var node = self.previousSiblingOrNull(self._current);
while (node) |n| {
if (try self.acceptNode(n) == NodeFilter.FILTER_ACCEPT) {
if (try self.acceptNode(n, page) == NodeFilter.FILTER_ACCEPT) {
self._current = n;
return n;
}
@@ -169,10 +169,10 @@ pub fn previousSibling(self: *DOMTreeWalker) !?*Node {
return null;
}
pub fn nextSibling(self: *DOMTreeWalker) !?*Node {
pub fn nextSibling(self: *DOMTreeWalker, page: *Page) !?*Node {
var node = self.nextSiblingOrNull(self._current);
while (node) |n| {
if (try self.acceptNode(n) == NodeFilter.FILTER_ACCEPT) {
if (try self.acceptNode(n, page) == NodeFilter.FILTER_ACCEPT) {
self._current = n;
return n;
}
@@ -181,7 +181,7 @@ pub fn nextSibling(self: *DOMTreeWalker) !?*Node {
return null;
}
pub fn previousNode(self: *DOMTreeWalker) !?*Node {
pub fn previousNode(self: *DOMTreeWalker, page: *Page) !?*Node {
var node = self._current;
while (node != self._root) {
var sibling = self.previousSiblingOrNull(node);
@@ -189,7 +189,7 @@ pub fn previousNode(self: *DOMTreeWalker) !?*Node {
node = sib;
// Check if this sibling is rejected before descending into it
const sib_result = try self.acceptNode(node);
const sib_result = try self.acceptNode(node, page);
if (sib_result == NodeFilter.FILTER_REJECT) {
// Skip this sibling and its descendants entirely
sibling = self.previousSiblingOrNull(node);
@@ -204,7 +204,7 @@ pub fn previousNode(self: *DOMTreeWalker) !?*Node {
while (child) |c| {
if (!self.isInSubtree(c)) break;
const filter_result = try self.acceptNode(c);
const filter_result = try self.acceptNode(c, page);
if (filter_result == NodeFilter.FILTER_REJECT) {
// Skip this child and try its previous sibling
child = self.previousSiblingOrNull(c);
@@ -220,7 +220,7 @@ pub fn previousNode(self: *DOMTreeWalker) !?*Node {
node = child.?;
}
if (try self.acceptNode(node) == NodeFilter.FILTER_ACCEPT) {
if (try self.acceptNode(node, page) == NodeFilter.FILTER_ACCEPT) {
self._current = node;
return node;
}
@@ -232,7 +232,7 @@ pub fn previousNode(self: *DOMTreeWalker) !?*Node {
}
const parent = node._parent orelse return null;
if (try self.acceptNode(parent) == NodeFilter.FILTER_ACCEPT) {
if (try self.acceptNode(parent, page) == NodeFilter.FILTER_ACCEPT) {
self._current = parent;
return parent;
}
@@ -241,14 +241,14 @@ pub fn previousNode(self: *DOMTreeWalker) !?*Node {
return null;
}
pub fn nextNode(self: *DOMTreeWalker) !?*Node {
pub fn nextNode(self: *DOMTreeWalker, page: *Page) !?*Node {
var node = self._current;
while (true) {
// Try children first (depth-first)
if (node.firstChild()) |child| {
node = child;
const filter_result = try self.acceptNode(node);
const filter_result = try self.acceptNode(node, page);
if (filter_result == NodeFilter.FILTER_ACCEPT) {
self._current = node;
return node;
@@ -271,7 +271,7 @@ pub fn nextNode(self: *DOMTreeWalker) !?*Node {
if (node.nextSibling()) |sibling| {
node = sibling;
const filter_result = try self.acceptNode(node);
const filter_result = try self.acceptNode(node, page);
if (filter_result == NodeFilter.FILTER_ACCEPT) {
self._current = node;
return node;
@@ -293,7 +293,7 @@ pub fn nextNode(self: *DOMTreeWalker) !?*Node {
}
// Helper methods
fn acceptNode(self: *const DOMTreeWalker, node: *Node) !i32 {
fn acceptNode(self: *const DOMTreeWalker, node: *Node, page: *Page) !i32 {
// First check whatToShow
if (!NodeFilter.shouldShow(node, self._what_to_show)) {
return NodeFilter.FILTER_SKIP;
@@ -303,7 +303,7 @@ fn acceptNode(self: *const DOMTreeWalker, node: *Node) !i32 {
// For TreeWalker, REJECT means reject node and its descendants
// SKIP means skip node but check its descendants
// ACCEPT means accept the node
return try self._filter.acceptNode(node);
return try self._filter.acceptNode(node, page.js.local.?);
}
fn isInSubtree(self: *const DOMTreeWalker, node: *Node) bool {

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -30,12 +30,11 @@ const Location = @import("Location.zig");
const Parser = @import("../parser/Parser.zig");
const collections = @import("collections.zig");
const Selector = @import("selector/Selector.zig");
const NodeFilter = @import("NodeFilter.zig");
const DocumentType = @import("DocumentType.zig");
const DOMTreeWalker = @import("DOMTreeWalker.zig");
const DOMNodeIterator = @import("DOMNodeIterator.zig");
const DOMImplementation = @import("DOMImplementation.zig");
const StyleSheetList = @import("css/StyleSheetList.zig");
const Selection = @import("Selection.zig");
pub const XMLDocument = @import("XMLDocument.zig");
pub const HTMLDocument = @import("HTMLDocument.zig");
@@ -55,6 +54,21 @@ _style_sheets: ?*StyleSheetList = null,
_write_insertion_point: ?*Node = null,
_script_created_parser: ?Parser.Streaming = null,
_adopted_style_sheets: ?js.Object.Global = null,
_selection: Selection = .init,
_on_selectionchange: ?js.Function.Global = null,
pub fn getOnSelectionChange(self: *Document) ?js.Function.Global {
return self._on_selectionchange;
}
pub fn setOnSelectionChange(self: *Document, listener: ?js.Function) !void {
if (listener) |listen| {
self._on_selectionchange = try listen.persistWithThis(self);
} else {
self._on_selectionchange = null;
}
}
pub const Type = union(enum) {
generic,
@@ -103,18 +117,6 @@ pub fn getContentType(self: *const Document) []const u8 {
};
}
pub fn getCharacterSet(_: *const Document) []const u8 {
return "UTF-8";
}
pub fn getCompatMode(_: *const Document) []const u8 {
return "CSS1Compat";
}
pub fn getReferrer(_: *const Document) []const u8 {
return "";
}
pub fn getDomain(_: *const Document, page: *const Page) []const u8 {
return URL.getHostname(page.url);
}
@@ -125,14 +127,16 @@ const CreateElementOptions = struct {
pub fn createElement(self: *Document, name: []const u8, options_: ?CreateElementOptions, page: *Page) !*Element {
try validateElementName(name);
const namespace: Element.Namespace = blk: {
const ns: Element.Namespace, const normalized_name = blk: {
if (self._type == .html) {
break :blk .html;
break :blk .{ .html, std.ascii.lowerString(&page.buf, name) };
}
// Generic and XML documents create XML elements
break :blk .xml;
break :blk .{ .xml, name };
};
const node = try page.createElementNS(namespace, name, null);
// HTML documents are case-insensitive - lowercase the tag name
const node = try page.createElementNS(ns, normalized_name, null);
const element = node.as(Element);
// Track owner document if it's not the main document
@@ -142,7 +146,7 @@ pub fn createElement(self: *Document, name: []const u8, options_: ?CreateElement
const options = options_ orelse return element;
if (options.is) |is_value| {
try element.setAttribute("is", is_value, page);
try element.setAttribute(comptime .wrap("is"), .wrap(is_value), page);
try Element.Html.Custom.checkAndAttachBuiltIn(element, page);
}
@@ -151,7 +155,9 @@ pub fn createElement(self: *Document, name: []const u8, options_: ?CreateElement
pub fn createElementNS(self: *Document, namespace: ?[]const u8, name: []const u8, page: *Page) !*Element {
try validateElementName(name);
const node = try page.createElementNS(Element.Namespace.parse(namespace), name, null);
const ns = Element.Namespace.parse(namespace);
const normalized_name = if (ns == .html) std.ascii.lowerString(&page.buf, name) else name;
const node = try page.createElementNS(ns, normalized_name, null);
// Track owner document if it's not the main document
if (self != page.document) {
@@ -160,26 +166,26 @@ pub fn createElementNS(self: *Document, namespace: ?[]const u8, name: []const u8
return node.as(Element);
}
pub fn createAttribute(_: *const Document, name: []const u8, page: *Page) !?*Element.Attribute {
try Element.Attribute.validateAttributeName(name);
pub fn createAttribute(_: *const Document, name: String.Global, page: *Page) !?*Element.Attribute {
try Element.Attribute.validateAttributeName(name.str);
return page._factory.node(Element.Attribute{
._proto = undefined,
._name = try page.dupeString(name),
._value = "",
._name = name.str,
._value = String.empty,
._element = null,
});
}
pub fn createAttributeNS(_: *const Document, namespace: []const u8, name: []const u8, page: *Page) !?*Element.Attribute {
pub fn createAttributeNS(_: *const Document, namespace: []const u8, name: String.Global, page: *Page) !?*Element.Attribute {
if (std.mem.eql(u8, namespace, "http://www.w3.org/1999/xhtml") == false) {
log.warn(.not_implemented, "document.createAttributeNS", .{ .namespace = namespace });
}
try Element.Attribute.validateAttributeName(name);
try Element.Attribute.validateAttributeName(name.str);
return page._factory.node(Element.Attribute{
._proto = undefined,
._name = try page.dupeString(name),
._value = "",
._name = name.str,
._value = String.empty,
._element = null,
});
}
@@ -197,7 +203,7 @@ pub fn getElementById(self: *Document, id: []const u8, page: *Page) ?*Element {
if (self._removed_ids.remove(id)) {
var tw = @import("TreeWalker.zig").Full.Elements.init(self.asNode(), .{});
while (tw.next()) |el| {
const element_id = el.getAttributeSafe("id") orelse continue;
const element_id = el.getAttributeSafe(comptime .wrap("id")) orelse continue;
if (std.mem.eql(u8, element_id, id)) {
// we ignore this error to keep getElementById easy to call
// if it really failed, then we're out of memory and nothing's
@@ -247,7 +253,7 @@ pub fn getElementsByClassName(self: *Document, class_name: []const u8, page: *Pa
// Parse space-separated class names
var class_names: std.ArrayList([]const u8) = .empty;
var it = std.mem.tokenizeAny(u8, class_name, &std.ascii.whitespace);
var it = std.mem.tokenizeAny(u8, class_name, "\t\n\x0C\r ");
while (it.next()) |name| {
try class_names.append(arena, try page.dupeString(name));
}
@@ -276,12 +282,16 @@ pub fn getDocumentElement(self: *Document) ?*Element {
return null;
}
pub fn querySelector(self: *Document, input: []const u8, page: *Page) !?*Element {
return Selector.querySelector(self.asNode(), input, page);
pub fn getSelection(self: *Document) *Selection {
return &self._selection;
}
pub fn querySelectorAll(self: *Document, input: []const u8, page: *Page) !*Selector.List {
return Selector.querySelectorAll(self.asNode(), input, page);
pub fn querySelector(self: *Document, input: String, page: *Page) !?*Element {
return Selector.querySelector(self.asNode(), input.str(), page);
}
pub fn querySelectorAll(self: *Document, input: String, page: *Page) !*Selector.List {
return Selector.querySelectorAll(self.asNode(), input.str(), page);
}
pub fn getImplementation(_: *const Document) DOMImplementation {
@@ -642,7 +652,10 @@ pub fn write(self: *Document, text: []const []const u8, page: *Page) !void {
page._parse_mode = .document_write;
defer page._parse_mode = previous_parse_mode;
var parser = Parser.init(page.call_arena, fragment_node, page);
const arena = try page.getArena(.{ .debug = "Document.write" });
defer page.releaseArena(arena);
var parser = Parser.init(arena, fragment_node, page);
parser.parseFragment(html);
// Extract children from wrapper HTML element (html5ever wraps fragments)
@@ -655,7 +668,7 @@ pub fn write(self: *Document, text: []const []const u8, page: *Page) !void {
var it = if (first.is(Element.Html.Html) == null) fragment_node.childrenIterator() else first.childrenIterator();
while (it.next()) |child| {
try children_to_insert.append(page.call_arena, child);
try children_to_insert.append(arena, child);
}
if (children_to_insert.items.len == 0) {
@@ -770,7 +783,7 @@ pub fn getAdoptedStyleSheets(self: *Document, page: *Page) !js.Object.Global {
if (self._adopted_style_sheets) |ass| {
return ass;
}
const js_arr = page.js.newArray(0);
const js_arr = page.js.local.?.newArray(0);
const js_obj = js_arr.toObject();
self._adopted_style_sheets = try js_obj.persist();
return self._adopted_style_sheets.?;
@@ -785,6 +798,17 @@ pub fn setAdoptedStyleSheets(self: *Document, sheets: js.Object) !void {
self._adopted_style_sheets = try sheets.persist();
}
pub fn getHidden(_: *const Document) bool {
// it's hidden when, for example, the decive is locked, or user is on a
// a different tab.
return false;
}
pub fn getVisibilityState(_: *const Document) []const u8 {
// See getHidden above, possible options are "visible" or "hidden"
return "visible";
}
// Validates that nodes can be inserted into a Document, respecting Document constraints:
// - At most one Element child
// - At most one DocumentType child
@@ -920,6 +944,7 @@ pub const JsApi = struct {
});
}
pub const onselectionchange = bridge.accessor(Document.getOnSelectionChange, Document.setOnSelectionChange, .{});
pub const URL = bridge.accessor(Document.getURL, null, .{});
pub const documentURI = bridge.accessor(Document.getURL, null, .{});
pub const documentElement = bridge.accessor(Document.getDocumentElement, null, .{});
@@ -929,11 +954,6 @@ pub const JsApi = struct {
pub const activeElement = bridge.accessor(Document.getActiveElement, null, .{});
pub const styleSheets = bridge.accessor(Document.getStyleSheets, null, .{});
pub const contentType = bridge.accessor(Document.getContentType, null, .{});
pub const characterSet = bridge.accessor(Document.getCharacterSet, null, .{});
pub const charset = bridge.accessor(Document.getCharacterSet, null, .{});
pub const inputEncoding = bridge.accessor(Document.getCharacterSet, null, .{});
pub const compatMode = bridge.accessor(Document.getCompatMode, null, .{});
pub const referrer = bridge.accessor(Document.getReferrer, null, .{});
pub const domain = bridge.accessor(Document.getDomain, null, .{});
pub const createElement = bridge.function(Document.createElement, .{ .dom_exception = true });
pub const createElementNS = bridge.function(Document.createElementNS, .{ .dom_exception = true });
@@ -962,6 +982,7 @@ pub const JsApi = struct {
pub const querySelector = bridge.function(Document.querySelector, .{ .dom_exception = true });
pub const querySelectorAll = bridge.function(Document.querySelectorAll, .{ .dom_exception = true });
pub const getElementsByTagName = bridge.function(Document.getElementsByTagName, .{});
pub const getSelection = bridge.function(Document.getSelection, .{});
pub const getElementsByClassName = bridge.function(Document.getElementsByClassName, .{});
pub const getElementsByName = bridge.function(Document.getElementsByName, .{});
pub const adoptNode = bridge.function(Document.adoptNode, .{ .dom_exception = true });
@@ -979,13 +1000,21 @@ pub const JsApi = struct {
pub const lastElementChild = bridge.accessor(Document.getLastElementChild, null, .{});
pub const childElementCount = bridge.accessor(Document.getChildElementCount, null, .{});
pub const adoptedStyleSheets = bridge.accessor(Document.getAdoptedStyleSheets, Document.setAdoptedStyleSheets, .{});
pub const hidden = bridge.accessor(Document.getHidden, null, .{});
pub const visibilityState = bridge.accessor(Document.getVisibilityState, null, .{});
pub const defaultView = bridge.accessor(struct {
fn defaultView(_: *const Document, page: *Page) *@import("Window.zig") {
return page.window;
}
}.defaultView, null, .{ .cache = "defaultView" });
}.defaultView, null, .{});
pub const hasFocus = bridge.function(Document.hasFocus, .{});
pub const prerendering = bridge.property(false, .{ .template = false });
pub const characterSet = bridge.property("UTF-8", .{ .template = false });
pub const charset = bridge.property("UTF-8", .{ .template = false });
pub const inputEncoding = bridge.property("UTF-8", .{ .template = false });
pub const compatMode = bridge.property("CSS1Compat", .{ .template = false });
pub const referrer = bridge.property("", .{ .template = false });
};
const testing = @import("../../testing.zig");

View File

@@ -74,7 +74,7 @@ pub fn getElementById(self: *DocumentFragment, id: []const u8) ?*Element {
var tw = @import("TreeWalker.zig").Full.Elements.init(self.asNode(), .{});
while (tw.next()) |el| {
if (el.getAttributeSafe("id")) |element_id| {
if (el.getAttributeSafe(comptime .wrap("id"))) |element_id| {
if (std.mem.eql(u8, element_id, id)) {
return el;
}

View File

@@ -235,6 +235,7 @@ pub fn getTagNameLower(self: *const Element) []const u8 {
.option => "option",
.output => "output",
.p => "p",
.picture => "picture",
.param => "param",
.pre => "pre",
.progress => "progress",
@@ -311,6 +312,7 @@ pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 {
.option => "OPTION",
.output => "OUTPUT",
.p => "P",
.picture => "PICTURE",
.param => "PARAM",
.pre => "PRE",
.progress => "PROGRESS",
@@ -427,35 +429,35 @@ pub fn setInnerHTML(self: *Element, html: []const u8, page: *Page) !void {
}
pub fn getId(self: *const Element) []const u8 {
return self.getAttributeSafe("id") orelse "";
return self.getAttributeSafe(comptime .wrap("id")) orelse "";
}
pub fn setId(self: *Element, value: []const u8, page: *Page) !void {
return self.setAttributeSafe("id", value, page);
return self.setAttributeSafe(comptime .wrap("id"), .wrap(value), page);
}
pub fn getSlot(self: *const Element) []const u8 {
return self.getAttributeSafe("slot") orelse "";
return self.getAttributeSafe(comptime .wrap("slot")) orelse "";
}
pub fn setSlot(self: *Element, value: []const u8, page: *Page) !void {
return self.setAttributeSafe("slot", value, page);
return self.setAttributeSafe(comptime .wrap("slot"), .wrap(value), page);
}
pub fn getDir(self: *const Element) []const u8 {
return self.getAttributeSafe("dir") orelse "";
return self.getAttributeSafe(comptime .wrap("dir")) orelse "";
}
pub fn setDir(self: *Element, value: []const u8, page: *Page) !void {
return self.setAttributeSafe("dir", value, page);
return self.setAttributeSafe(comptime .wrap("dir"), .wrap(value), page);
}
pub fn getClassName(self: *const Element) []const u8 {
return self.getAttributeSafe("class") orelse "";
return self.getAttributeSafe(comptime .wrap("class")) orelse "";
}
pub fn setClassName(self: *Element, value: []const u8, page: *Page) !void {
return self.setAttributeSafe("class", value, page);
return self.setAttributeSafe(comptime .wrap("class"), .wrap(value), page);
}
pub fn attributeIterator(self: *Element) Attribute.InnerIterator {
@@ -463,7 +465,7 @@ pub fn attributeIterator(self: *Element) Attribute.InnerIterator {
return attributes.iterator();
}
pub fn getAttribute(self: *const Element, name: []const u8, page: *Page) !?[]const u8 {
pub fn getAttribute(self: *const Element, name: String, page: *Page) !?String {
const attributes = self._attributes orelse return null;
return attributes.get(name, page);
}
@@ -472,9 +474,9 @@ pub fn getAttribute(self: *const Element, name: []const u8, page: *Page) !?[]con
pub fn getAttributeNS(
self: *const Element,
maybe_namespace: ?[]const u8,
local_name: []const u8,
local_name: String,
page: *Page,
) !?[]const u8 {
) !?String {
if (maybe_namespace) |namespace| {
if (!std.mem.eql(u8, namespace, "http://www.w3.org/1999/xhtml")) {
log.warn(.not_implemented, "Element.getAttributeNS", .{ .namespace = namespace });
@@ -484,18 +486,18 @@ pub fn getAttributeNS(
return self.getAttribute(local_name, page);
}
pub fn getAttributeSafe(self: *const Element, name: []const u8) ?[]const u8 {
pub fn getAttributeSafe(self: *const Element, name: String) ?[]const u8 {
const attributes = self._attributes orelse return null;
return attributes.getSafe(name);
}
pub fn hasAttribute(self: *const Element, name: []const u8, page: *Page) !bool {
pub fn hasAttribute(self: *const Element, name: String, page: *Page) !bool {
const attributes = self._attributes orelse return false;
const value = try attributes.get(name, page);
return value != null;
}
pub fn hasAttributeSafe(self: *const Element, name: []const u8) bool {
pub fn hasAttributeSafe(self: *const Element, name: String) bool {
const attributes = self._attributes orelse return false;
return attributes.hasSafe(name);
}
@@ -505,12 +507,12 @@ pub fn hasAttributes(self: *const Element) bool {
return attributes.isEmpty() == false;
}
pub fn getAttributeNode(self: *Element, name: []const u8, page: *Page) !?*Attribute {
pub fn getAttributeNode(self: *Element, name: String, page: *Page) !?*Attribute {
const attributes = self._attributes orelse return null;
return attributes.getAttribute(name, self, page);
}
pub fn setAttribute(self: *Element, name: []const u8, value: []const u8, page: *Page) !void {
pub fn setAttribute(self: *Element, name: String, value: String, page: *Page) !void {
try Attribute.validateAttributeName(name);
const attributes = try self.getOrCreateAttributeList(page);
_ = try attributes.put(name, value, self, page);
@@ -520,7 +522,7 @@ pub fn setAttributeNS(
self: *Element,
maybe_namespace: ?[]const u8,
qualified_name: []const u8,
value: []const u8,
value: String,
page: *Page,
) !void {
if (maybe_namespace) |namespace| {
@@ -533,10 +535,10 @@ pub fn setAttributeNS(
qualified_name[idx + 1 ..]
else
qualified_name;
return self.setAttribute(local_name, value, page);
return self.setAttribute(.wrap(local_name), value, page);
}
pub fn setAttributeSafe(self: *Element, name: []const u8, value: []const u8, page: *Page) !void {
pub fn setAttributeSafe(self: *Element, name: String, value: String, page: *Page) !void {
const attributes = try self.getOrCreateAttributeList(page);
_ = try attributes.putSafe(name, value, self, page);
}
@@ -607,19 +609,19 @@ pub fn setAttributeNode(self: *Element, attr: *Attribute, page: *Page) !?*Attrib
return attributes.putAttribute(attr, self, page);
}
pub fn removeAttribute(self: *Element, name: []const u8, page: *Page) !void {
pub fn removeAttribute(self: *Element, name: String, page: *Page) !void {
const attributes = self._attributes orelse return;
return attributes.delete(name, self, page);
}
pub fn toggleAttribute(self: *Element, name: []const u8, force: ?bool, page: *Page) !bool {
pub fn toggleAttribute(self: *Element, name: String, force: ?bool, page: *Page) !bool {
try Attribute.validateAttributeName(name);
const has = try self.hasAttribute(name, page);
const should_add = force orelse !has;
if (should_add and !has) {
try self.setAttribute(name, "", page);
try self.setAttribute(name, String.empty, page);
return true;
} else if (!should_add and has) {
try self.removeAttribute(name, page);
@@ -666,7 +668,7 @@ pub fn getClassList(self: *Element, page: *Page) !*collections.DOMTokenList {
if (!gop.found_existing) {
gop.value_ptr.* = try page._factory.create(collections.DOMTokenList{
._element = self,
._attribute_name = "class",
._attribute_name = comptime .wrap("class"),
});
}
return gop.value_ptr.*;
@@ -677,7 +679,7 @@ pub fn getRelList(self: *Element, page: *Page) !*collections.DOMTokenList {
if (!gop.found_existing) {
gop.value_ptr.* = try page._factory.create(collections.DOMTokenList{
._element = self,
._attribute_name = "rel",
._attribute_name = comptime .wrap("rel"),
});
}
return gop.value_ptr.*;
@@ -714,6 +716,44 @@ pub fn replaceChildren(self: *Element, nodes: []const Node.NodeOrText, page: *Pa
}
}
pub fn replaceWith(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void {
page.domChanged();
const ref_node = self.asNode();
const parent = ref_node._parent orelse return;
const parent_is_connected = parent.isConnected();
// Detect if the ref_node must be removed (byt default) or kept.
// We kept it when ref_node is present into the nodes list.
var rm_ref_node = true;
for (nodes) |node_or_text| {
const child = try node_or_text.toNode(page);
// If a child is the ref node. We keep it at its own current position.
if (child == ref_node) {
rm_ref_node = false;
continue;
}
if (child._parent) |current_parent| {
page.removeNode(current_parent, child, .{ .will_be_reconnected = parent_is_connected });
}
try page.insertNodeRelative(
parent,
child,
.{ .before = ref_node },
.{ .child_already_connected = child.isConnected() },
);
}
if (rm_ref_node) {
page.removeNode(parent, ref_node, .{ .will_be_reconnected = false });
}
}
pub fn remove(self: *Element, page: *Page) void {
page.domChanged();
const node = self.asNode();
@@ -729,7 +769,8 @@ pub fn focus(self: *Element, page: *Page) !void {
return;
}
const blur_event = try Event.initTrusted("blur", null, page);
const blur_event = try Event.initTrusted(comptime .wrap("blur"), null, page);
defer if (!blur_event._v8_handoff) blur_event.deinit(false);
try page._event_manager.dispatch(old.asEventTarget(), blur_event);
}
@@ -737,7 +778,8 @@ pub fn focus(self: *Element, page: *Page) !void {
page.document._active_element = self;
}
const focus_event = try Event.initTrusted("focus", null, page);
const focus_event = try Event.initTrusted(comptime .wrap("focus"), null, page);
defer if (!focus_event._v8_handoff) focus_event.deinit(false);
try page._event_manager.dispatch(self.asEventTarget(), focus_event);
}
@@ -747,7 +789,8 @@ pub fn blur(self: *Element, page: *Page) !void {
page.document._active_element = null;
const Event = @import("Event.zig");
const blur_event = try Event.initTrusted("blur", null, page);
const blur_event = try Event.initTrusted(comptime .wrap("blur"), null, page);
defer if (!blur_event._v8_handoff) blur_event.deinit(false);
try page._event_manager.dispatch(self.asEventTarget(), blur_event);
}
@@ -919,10 +962,10 @@ fn getElementDimensions(self: *Element, page: *Page) !struct { width: f64, heigh
if (width == 5.0) width = 1920.0;
if (height == 5.0) height = 100_000_000.0;
} else if (tag == .img or tag == .iframe) {
if (self.getAttributeSafe("width")) |w| {
if (self.getAttributeSafe(comptime .wrap("width"))) |w| {
width = std.fmt.parseFloat(f64, w) catch width;
}
if (self.getAttributeSafe("height")) |h| {
if (self.getAttributeSafe(comptime .wrap("height"))) |h| {
height = std.fmt.parseFloat(f64, h) catch height;
}
}
@@ -1097,7 +1140,7 @@ pub fn getElementsByClassName(self: *Element, class_name: []const u8, page: *Pag
// Parse space-separated class names
var class_names: std.ArrayList([]const u8) = .empty;
var it = std.mem.tokenizeAny(u8, class_name, &std.ascii.whitespace);
var it = std.mem.tokenizeAny(u8, class_name, "\t\n\x0C\r ");
while (it.next()) |name| {
try class_names.append(arena, try page.dupeString(name));
}
@@ -1132,6 +1175,14 @@ pub fn scrollIntoViewIfNeeded(_: *const Element, center_if_needed: ?bool) void {
_ = center_if_needed;
}
const ScrollIntoViewOpts = union {
align_to_top: bool,
obj: js.Object,
};
pub fn scrollIntoView(_: *const Element, opts: ?ScrollIntoViewOpts) void {
_ = opts;
}
pub fn format(self: *Element, writer: *std.Io.Writer) !void {
try writer.writeByte('<');
try writer.writeAll(self.getTagNameDump());
@@ -1175,26 +1226,27 @@ pub fn getTag(self: *const Element) Tag {
.data => .data,
.datalist => .datalist,
.dialog => .dialog,
.directory => .unknown,
.directory => .directory,
.iframe => .iframe,
.img => .img,
.br => .br,
.button => .button,
.canvas => .canvas,
.fieldset => .fieldset,
.font => .unknown,
.font => .font,
.heading => |h| h._tag,
.label => .unknown,
.legend => .unknown,
.label => .label,
.legend => .legend,
.li => .li,
.map => .unknown,
.map => .map,
.ul => .ul,
.ol => .ol,
.object => .unknown,
.object => .object,
.optgroup => .optgroup,
.output => .unknown,
.param => .unknown,
.pre => .unknown,
.output => .output,
.picture => .picture,
.param => .param,
.pre => .pre,
.generic => |g| g._tag,
.media => |m| switch (m._type) {
.audio => .audio,
@@ -1208,7 +1260,7 @@ pub fn getTag(self: *const Element) Tag {
.script => .script,
.select => .select,
.slot => .slot,
.source => .unknown,
.source => .source,
.span => .span,
.option => .option,
.table => .table,
@@ -1220,7 +1272,7 @@ pub fn getTag(self: *const Element) Tag {
.template => .template,
.textarea => .textarea,
.time => .time,
.track => .unknown,
.track => .track,
.input => .input,
.link => .link,
.meta => .meta,
@@ -1267,6 +1319,7 @@ pub const Tag = enum {
dfn,
dialog,
div,
directory,
dl,
dt,
embed,
@@ -1275,6 +1328,7 @@ pub const Tag = enum {
fieldset,
figure,
form,
font,
footer,
g,
h1,
@@ -1294,10 +1348,13 @@ pub const Tag = enum {
img,
input,
ins,
label,
legend,
li,
line,
link,
main,
map,
marquee,
media,
menu,
@@ -1313,8 +1370,10 @@ pub const Tag = enum {
p,
path,
param,
picture,
polygon,
polyline,
pre,
progress,
quote,
rect,
@@ -1405,6 +1464,16 @@ pub const JsApi = struct {
return null;
}
pub const setAttribute = bridge.function(_setAttribute, .{ .dom_exception = true });
fn _setAttribute(self: *Element, name: String, value: js.Value, page: *Page) !void {
return self.setAttribute(name, .wrap(try value.toStringSlice()), page);
}
pub const setAttributeNS = bridge.function(_setAttributeNS, .{ .dom_exception = true });
fn _setAttributeNS(self: *Element, maybe_ns: ?[]const u8, qn: []const u8, value: js.Value, page: *Page) !void {
return self.setAttributeNS(maybe_ns, qn, .wrap(try value.toStringSlice()), page);
}
pub const localName = bridge.accessor(Element.getLocalName, null, .{});
pub const id = bridge.accessor(Element.getId, Element.setId, .{});
pub const slot = bridge.accessor(Element.getSlot, Element.setSlot, .{});
@@ -1419,8 +1488,6 @@ pub const JsApi = struct {
pub const getAttribute = bridge.function(Element.getAttribute, .{});
pub const getAttributeNS = bridge.function(Element.getAttributeNS, .{});
pub const getAttributeNode = bridge.function(Element.getAttributeNode, .{});
pub const setAttribute = bridge.function(Element.setAttribute, .{ .dom_exception = true });
pub const setAttributeNS = bridge.function(Element.setAttributeNS, .{ .dom_exception = true });
pub const setAttributeNode = bridge.function(Element.setAttributeNode, .{});
pub const removeAttribute = bridge.function(Element.removeAttribute, .{});
pub const toggleAttribute = bridge.function(Element.toggleAttribute, .{ .dom_exception = true });
@@ -1440,6 +1507,7 @@ pub const JsApi = struct {
return self.attachShadow(init.mode, page);
}
pub const replaceChildren = bridge.function(Element.replaceChildren, .{});
pub const replaceWith = bridge.function(Element.replaceWith, .{});
pub const remove = bridge.function(Element.remove, .{});
pub const append = bridge.function(Element.append, .{});
pub const prepend = bridge.function(Element.prepend, .{});
@@ -1466,6 +1534,7 @@ pub const JsApi = struct {
pub const children = bridge.accessor(Element.getChildren, null, .{});
pub const focus = bridge.function(Element.focus, .{});
pub const blur = bridge.function(Element.blur, .{});
pub const scrollIntoView = bridge.function(Element.scrollIntoView, .{});
pub const scrollIntoViewIfNeeded = bridge.function(Element.scrollIntoViewIfNeeded, .{});
};

View File

@@ -24,11 +24,14 @@ const EventTarget = @import("EventTarget.zig");
const Node = @import("Node.zig");
const String = @import("../../string.zig").String;
const Allocator = std.mem.Allocator;
pub const Event = @This();
pub const _prototype_root = true;
_type: Type,
_page: *Page,
_arena: Allocator,
_bubbles: bool = false,
_cancelable: bool = false,
_composed: bool = false,
@@ -44,6 +47,12 @@ _time_stamp: u64,
_needs_retargeting: bool = false,
_isTrusted: bool = false,
// There's a period of time between creating an event and handing it off to v8
// where things can fail. If it does fail, we need to deinit the event. This flag
// when true, tells us the event is registered in the js.Contxt and thus, at
// the very least, will be finalized on context shutdown.
_v8_handoff: bool = false,
pub const EventPhase = enum(u8) {
none = 0,
capturing_phase = 1,
@@ -62,6 +71,7 @@ pub const Type = union(enum) {
page_transition_event: *@import("event/PageTransitionEvent.zig"),
pop_state_event: *@import("event/PopStateEvent.zig"),
ui_event: *@import("event/UIEvent.zig"),
promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"),
};
pub const Options = struct {
@@ -70,31 +80,38 @@ pub const Options = struct {
composed: bool = false,
};
pub fn initTrusted(typ: []const u8, opts_: ?Options, page: *Page) !*Event {
return initWithTrusted(typ, opts_, true, page);
}
pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event {
return initWithTrusted(typ, opts_, false, page);
const arena = try page.getArena(.{ .debug = "Event" });
errdefer page.releaseArena(arena);
const str = try String.init(arena, typ, .{});
return initWithTrusted(arena, str, opts_, false, page);
}
fn initWithTrusted(typ: []const u8, opts_: ?Options, trusted: bool, page: *Page) !*Event {
pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*Event {
const arena = try page.getArena(.{ .debug = "Event.trusted" });
errdefer page.releaseArena(arena);
return initWithTrusted(arena, typ, opts_, true, page);
}
fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, page: *Page) !*Event {
const opts = opts_ orelse Options{};
// Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2;
const event = try page._factory.create(Event{
const event = try arena.create(Event);
event.* = .{
._page = page,
._arena = arena,
._type = .generic,
._bubbles = opts.bubbles,
._time_stamp = time_stamp,
._cancelable = opts.cancelable,
._composed = opts.composed,
._type_string = try String.init(page.arena, typ, .{}),
});
event._isTrusted = trusted;
._type_string = typ,
._isTrusted = trusted,
};
return event;
}
@@ -103,18 +120,22 @@ pub fn initEvent(
event_string: []const u8,
bubbles: ?bool,
cancelable: ?bool,
page: *Page,
) !void {
if (self._event_phase != .none) {
return;
}
self._type_string = try String.init(page.arena, event_string, .{});
self._type_string = try String.init(self._arena, event_string, .{});
self._bubbles = bubbles orelse false;
self._cancelable = cancelable orelse false;
self._stop_propagation = false;
}
pub fn deinit(self: *Event, shutdown: bool) void {
_ = shutdown;
self._page.releaseArena(self._arena);
}
pub fn as(self: *Event, comptime T: type) *T {
return self.is(T).?;
}
@@ -130,6 +151,7 @@ pub fn is(self: *Event, comptime T: type) ?*T {
.navigation_current_entry_change_event => |e| return if (T == @import("event/NavigationCurrentEntryChangeEvent.zig")) e else null,
.page_transition_event => |e| return if (T == @import("event/PageTransitionEvent.zig")) e else null,
.pop_state_event => |e| return if (T == @import("event/PopStateEvent.zig")) e else null,
.promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null,
.ui_event => |e| {
if (T == @import("event/UIEvent.zig")) {
return e;
@@ -385,6 +407,8 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(Event.deinit);
};
pub const constructor = bridge.constructor(Event.init, .{});
@@ -410,10 +434,10 @@ pub const JsApi = struct {
pub const cancelBubble = bridge.accessor(Event.getCancelBubble, Event.setCancelBubble, .{});
// Event phase constants
pub const NONE = bridge.property(@intFromEnum(EventPhase.none));
pub const CAPTURING_PHASE = bridge.property(@intFromEnum(EventPhase.capturing_phase));
pub const AT_TARGET = bridge.property(@intFromEnum(EventPhase.at_target));
pub const BUBBLING_PHASE = bridge.property(@intFromEnum(EventPhase.bubbling_phase));
pub const NONE = bridge.property(@intFromEnum(EventPhase.none), .{ .template = true });
pub const CAPTURING_PHASE = bridge.property(@intFromEnum(EventPhase.capturing_phase), .{ .template = true });
pub const AT_TARGET = bridge.property(@intFromEnum(EventPhase.at_target), .{ .template = true });
pub const BUBBLING_PHASE = bridge.property(@intFromEnum(EventPhase.bubbling_phase), .{ .template = true });
};
// tested in event_target

View File

@@ -42,6 +42,7 @@ pub const Type = union(enum) {
navigation: *@import("navigation/NavigationEventTarget.zig"),
screen: *@import("Screen.zig"),
screen_orientation: *@import("Screen.zig").Orientation,
visual_viewport: *@import("VisualViewport.zig"),
};
pub fn init(page: *Page) !*EventTarget {
@@ -132,12 +133,13 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
.navigation => writer.writeAll("<Navigation>"),
.screen => writer.writeAll("<Screen>"),
.screen_orientation => writer.writeAll("<ScreenOrientation>"),
.visual_viewport => writer.writeAll("<VisualViewport>"),
};
}
pub fn toString(self: *EventTarget) []const u8 {
return switch (self._type) {
.node => |n| return n.className(),
.node => return "[object Node]",
.generic => return "[object EventTarget]",
.window => return "[object Window]",
.xhr => return "[object XMLHttpRequestEventTarget]",
@@ -148,6 +150,7 @@ pub fn toString(self: *EventTarget) []const u8 {
.navigation => return "[object Navigation]",
.screen => return "[object Screen]",
.screen_orientation => return "[object ScreenOrientation]",
.visual_viewport => return "[object VisualViewport]",
};
}

View File

@@ -261,7 +261,7 @@ pub const JsApi = struct {
pub const applets = bridge.accessor(HTMLDocument.getApplets, null, .{});
pub const plugins = bridge.accessor(HTMLDocument.getEmbeds, null, .{});
pub const currentScript = bridge.accessor(HTMLDocument.getCurrentScript, null, .{});
pub const location = bridge.accessor(HTMLDocument.getLocation, HTMLDocument.setLocation, .{ .cache = "location" });
pub const location = bridge.accessor(HTMLDocument.getLocation, HTMLDocument.setLocation, .{});
pub const all = bridge.accessor(HTMLDocument.getAll, null, .{});
pub const cookie = bridge.accessor(HTMLDocument.getCookie, HTMLDocument.setCookie, .{});
pub const doctype = bridge.accessor(HTMLDocument.getDocType, null, .{});

View File

@@ -34,7 +34,7 @@ pub fn getLength(_: *const History, page: *Page) u32 {
pub fn getState(_: *const History, page: *Page) !?js.Value {
if (page._session.navigation.getCurrentEntry()._state.value) |state| {
const value = try page.js.parseJSON(state);
const value = try page.js.local.?.parseJSON(state);
return value;
} else return null;
}
@@ -79,13 +79,13 @@ fn goInner(delta: i32, page: *Page) !void {
if (entry._url) |url| {
if (try page.isSameOrigin(url)) {
const event = try PopStateEvent.initTrusted("popstate", .{ .state = entry._state.value }, page);
const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent();
defer if (!event._v8_handoff) event.deinit(false);
const func = if (page.window._on_popstate) |*g| g.local() else null;
try page._event_manager.dispatchWithFunction(
page.window.asEventTarget(),
event.asEvent(),
func,
event,
page.js.toLocal(page.window._on_popstate),
.{ .context = "Pop State" },
);
}

View File

@@ -24,10 +24,6 @@ pub fn init() IdleDeadline {
return .{};
}
pub fn getDidTimeout(_: *const IdleDeadline) bool {
return false;
}
pub fn timeRemaining(_: *const IdleDeadline) f64 {
// Return a fixed 50ms.
// This allows idle callbacks to perform work without complex
@@ -47,5 +43,5 @@ pub const JsApi = struct {
};
pub const timeRemaining = bridge.function(IdleDeadline.timeRemaining, .{});
pub const didTimeout = bridge.accessor(IdleDeadline.getDidTimeout, null, .{});
pub const didTimeout = bridge.property(false, .{ .template = false });
};

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -19,6 +19,10 @@ const std = @import("std");
const js = @import("../js/js.zig");
const log = @import("../../log.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
const Allocator = std.mem.Allocator;
const Page = @import("../Page.zig");
const Element = @import("Element.zig");
const DOMRect = @import("DOMRect.zig");
@@ -32,7 +36,9 @@ pub fn registerTypes() []const type {
const IntersectionObserver = @This();
_callback: js.Function.Global,
_page: *Page,
_arena: Allocator,
_callback: js.Function.Temp,
_observing: std.ArrayList(*Element) = .{},
_root: ?*Element = null,
_root_margin: []const u8 = "0px",
@@ -59,25 +65,42 @@ pub const ObserverInit = struct {
};
};
pub fn init(callback: js.Function.Global, options: ?ObserverInit, page: *Page) !*IntersectionObserver {
pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*IntersectionObserver {
const arena = try page.getArena(.{ .debug = "IntersectionObserver" });
errdefer page.releaseArena(arena);
const opts = options orelse ObserverInit{};
const root_margin = if (opts.rootMargin) |rm| try page.arena.dupe(u8, rm) else "0px";
const root_margin = if (opts.rootMargin) |rm| try arena.dupe(u8, rm) else "0px";
const threshold = switch (opts.threshold) {
.scalar => |s| blk: {
const arr = try page.arena.alloc(f64, 1);
const arr = try arena.alloc(f64, 1);
arr[0] = s;
break :blk arr;
},
.array => |arr| try page.arena.dupe(f64, arr),
.array => |arr| try arena.dupe(f64, arr),
};
return page._factory.create(IntersectionObserver{
const self = try arena.create(IntersectionObserver);
self.* = .{
._page = page,
._arena = arena,
._callback = callback,
._root = opts.root,
._root_margin = root_margin,
._threshold = threshold,
});
};
return self;
}
pub fn deinit(self: *IntersectionObserver, shutdown: bool) void {
const page = self._page;
page.js.release(self._callback);
if ((comptime IS_DEBUG) and !shutdown) {
std.debug.assert(self._observing.items.len == 0);
}
page.releaseArena(self._arena);
}
pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void {
@@ -90,10 +113,11 @@ pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void
// Register with page if this is our first observation
if (self._observing.items.len == 0) {
page.js.strongRef(self);
try page.registerIntersectionObserver(self);
}
try self._observing.append(page.arena, target);
try self._observing.append(self._arena, target);
// Don't initialize previous state yet - let checkIntersection do it
// This ensures we get an entry on first observation
@@ -105,7 +129,7 @@ pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void
}
}
pub fn unobserve(self: *IntersectionObserver, target: *Element) void {
pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) void {
for (self._observing.items, 0..) |elem, i| {
if (elem == target) {
_ = self._observing.swapRemove(i);
@@ -115,21 +139,31 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element) void {
var j: usize = 0;
while (j < self._pending_entries.items.len) {
if (self._pending_entries.items[j]._target == target) {
_ = self._pending_entries.swapRemove(j);
const entry = self._pending_entries.swapRemove(j);
entry.deinit(false);
} else {
j += 1;
}
}
return;
break;
}
}
if (self._observing.items.len == 0) {
page.js.safeWeakRef(self);
}
}
pub fn disconnect(self: *IntersectionObserver, page: *Page) void {
page.unregisterIntersectionObserver(self);
self._observing.clearRetainingCapacity();
self._previous_states.clearRetainingCapacity();
for (self._pending_entries.items) |entry| {
entry.deinit(false);
}
self._pending_entries.clearRetainingCapacity();
page.js.safeWeakRef(self);
}
pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry {
@@ -206,8 +240,11 @@ fn checkIntersection(self: *IntersectionObserver, target: *Element, page: *Page)
(was_intersecting_opt != null and was_intersecting_opt.? != is_now_intersecting);
if (should_report) {
const entry = try page.arena.create(IntersectionObserverEntry);
const arena = try page.getArena(.{ .debug = "IntersectionObserverEntry" });
const entry = try arena.create(IntersectionObserverEntry);
entry.* = .{
._page = page,
._arena = arena,
._target = target,
._time = 0.0, // TODO: Get actual timestamp
._bounding_client_rect = data.bounding_client_rect,
@@ -217,12 +254,12 @@ fn checkIntersection(self: *IntersectionObserver, target: *Element, page: *Page)
._is_intersecting = is_now_intersecting,
};
try self._pending_entries.append(page.arena, entry);
try self._pending_entries.append(self._arena, entry);
}
// Always update the previous state, even if we didn't report
// This ensures we can detect state changes on subsequent checks
try self._previous_states.put(page.arena, target, is_now_intersecting);
try self._previous_states.put(self._arena, target, is_now_intersecting);
}
pub fn checkIntersections(self: *IntersectionObserver, page: *Page) !void {
@@ -246,21 +283,32 @@ pub fn deliverEntries(self: *IntersectionObserver, page: *Page) !void {
const entries = try self.takeRecords(page);
var caught: js.TryCatch.Caught = undefined;
self._callback.local().tryCall(void, .{ entries, self }, &caught) catch |err| {
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
ls.toLocal(self._callback).tryCall(void, .{ entries, self }, &caught) catch |err| {
log.err(.page, "IntsctObserver.deliverEntries", .{ .err = err, .caught = caught });
return err;
};
}
pub const IntersectionObserverEntry = struct {
_target: *Element,
_page: *Page,
_arena: Allocator,
_time: f64,
_target: *Element,
_bounding_client_rect: *DOMRect,
_intersection_rect: *DOMRect,
_root_bounds: *DOMRect,
_intersection_ratio: f64,
_is_intersecting: bool,
pub fn deinit(self: *const IntersectionObserverEntry, _: bool) void {
self._page.releaseArena(self._arena);
}
pub fn getTarget(self: *const IntersectionObserverEntry) *Element {
return self._target;
}
@@ -296,6 +344,8 @@ pub const IntersectionObserverEntry = struct {
pub const name = "IntersectionObserverEntry";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(IntersectionObserverEntry.deinit);
};
pub const target = bridge.accessor(IntersectionObserverEntry.getTarget, null, .{});
@@ -315,6 +365,8 @@ pub const JsApi = struct {
pub const name = "IntersectionObserver";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(IntersectionObserver.deinit);
};
pub const constructor = bridge.constructor(init, .{});

View File

@@ -46,7 +46,7 @@ pub const Entry = struct {
pub const KeyValueList = @This();
_entries: std.ArrayListUnmanaged(Entry) = .empty,
_entries: std.ArrayList(Entry) = .empty,
pub const empty: KeyValueList = .{
._entries = .empty,
@@ -68,12 +68,11 @@ pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?N
while (try it.next()) |name| {
const js_value = try js_obj.get(name);
const value = try js_value.toString(.{});
const normalized = if (comptime normalizer) |n| n(name, page) else name;
list._entries.appendAssumeCapacity(.{
.name = try String.init(arena, normalized, .{}),
.value = try String.init(arena, value, .{}),
.value = try js_value.toSSOWithAlloc(arena),
});
}

View File

@@ -48,7 +48,7 @@ pub fn entangle(port1: *MessagePort, port2: *MessagePort) void {
port2._entangled_port = port1;
}
pub fn postMessage(self: *MessagePort, message: js.Value.Global, page: *Page) !void {
pub fn postMessage(self: *MessagePort, message: js.Value.Temp, page: *Page) !void {
if (self._closed) {
return;
}
@@ -65,7 +65,7 @@ pub fn postMessage(self: *MessagePort, message: js.Value.Global, page: *Page) !v
.message = message,
});
try page.scheduler.add(callback, PostMessageCallback.run, 0, .{
try page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{
.name = "MessagePort.postMessage",
.low_priority = false,
});
@@ -106,7 +106,7 @@ pub fn setOnMessageError(self: *MessagePort, cb: ?js.Function.Global) !void {
const PostMessageCallback = struct {
port: *MessagePort,
message: js.Value.Global,
message: js.Value.Temp,
page: *Page,
fn deinit(self: *PostMessageCallback) void {
@@ -116,25 +116,30 @@ const PostMessageCallback = struct {
fn run(ctx: *anyopaque) !?u32 {
const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
defer self.deinit();
const page = self.page;
if (self.port._closed) {
return null;
}
const event = MessageEvent.initTrusted("message", .{
const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = self.message,
.origin = "",
.source = null,
}, self.page) catch |err| {
}, page) catch |err| {
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
return null;
};
}).asEvent();
defer if (!event._v8_handoff) event.deinit(false);
const func = if (self.port._on_message) |*g| g.local() else null;
self.page._event_manager.dispatchWithFunction(
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
page._event_manager.dispatchWithFunction(
self.port.asEventTarget(),
event.asEvent(),
func,
event,
ls.toLocal(self.port._on_message),
.{ .context = "MessagePort message" },
) catch |err| {
log.err(.dom, "MessagePort.postMessage", .{ .err = err });

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