221 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
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
Karl Seguin
8e8ffd21d5 add discord link to crash handler 2026-01-29 06:45:35 +08:00
153 changed files with 7343 additions and 3427 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.2.6'
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"
@@ -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 }})

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,7 +3,7 @@ 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.6
ARG ZIG_V8=v0.2.8
ARG TARGETPLATFORM
RUN apt-get update -yq && \

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.

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,8 +6,8 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/v0.2.6.tar.gz",
.hash = "v8-0.0.0-xddH60NRBAAWmpZq9nWdfFAEqVJ9zqJnvr1Nl9m2AbcY",
.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" },
.@"boringssl-zig" = .{

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,68 +21,38 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const log = @import("log.zig");
const Config = @import("Config.zig");
const Snapshot = @import("browser/js/Snapshot.zig");
const Platform = @import("browser/js/Platform.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");
pub const Notification = @import("Notification.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();
@@ -93,11 +63,9 @@ 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();
@@ -115,7 +83,7 @@ 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();

View File

@@ -56,6 +56,7 @@ pub fn deinit(self: *ArenaPool) void {
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();
}
@@ -72,7 +73,8 @@ pub fn release(self: *ArenaPool, allocator: Allocator) void {
const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr));
const entry: *Entry = @fieldParentPtr("arena", arena);
if (self.free_list_len == self.free_list_max) {
const free_list_len = self.free_list_len;
if (free_list_len == self.free_list_max) {
arena.deinit();
self.entry_pool.destroy(entry);
return;
@@ -80,5 +82,6 @@ pub fn release(self: *ArenaPool, allocator: Allocator) void {
_ = 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

@@ -27,11 +27,11 @@ const App = @import("../App.zig");
const ArenaPool = App.ArenaPool;
const HttpClient = App.Http.Client;
const Notification = App.Notification;
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.
@@ -48,25 +48,22 @@ 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),
@@ -83,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;
}
@@ -108,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 });
}
@@ -154,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);
},
}
@@ -199,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);
}
@@ -267,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;
@@ -278,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;
@@ -290,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;
@@ -302,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;
@@ -337,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;
@@ -419,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;
@@ -434,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

@@ -172,60 +172,42 @@ 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, typ: []const u8, mouse: MouseEvent, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
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(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);
// Set MouseEvent with all its fields
@@ -239,6 +221,20 @@ pub fn mouseEvent(self: *Factory, typ: []const u8, mouse: MouseEvent, child: any
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();

View File

@@ -33,7 +33,6 @@ const String = @import("../string.zig").String;
const Mime = @import("Mime.zig");
const Factory = @import("Factory.zig");
const Session = @import("Session.zig");
const Scheduler = @import("Scheduler.zig");
const EventManager = @import("EventManager.zig");
const ScriptManager = @import("ScriptManager.zig");
@@ -42,14 +41,17 @@ const Parser = @import("parser/Parser.zig");
const URL = @import("URL.zig");
const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const CData = @import("webapi/CData.zig");
const Element = @import("webapi/Element.zig");
const HtmlElement = @import("webapi/element/Html.zig");
const Window = @import("webapi/Window.zig");
const Location = @import("webapi/Location.zig");
const Document = @import("webapi/Document.zig");
const ShadowRoot = @import("webapi/ShadowRoot.zig");
const Performance = @import("webapi/Performance.zig");
const Screen = @import("webapi/Screen.zig");
const VisualViewport = @import("webapi/VisualViewport.zig");
const PerformanceObserver = @import("webapi/PerformanceObserver.zig");
const MutationObserver = @import("webapi/MutationObserver.zig");
const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
@@ -66,6 +68,9 @@ const timestamp = @import("../datetime.zig").timestamp;
const milliTimestamp = @import("../datetime.zig").milliTimestamp;
const WebApiURL = @import("webapi/URL.zig");
const global_event_handlers = @import("webapi/global_event_handlers.zig");
const GlobalEventHandlersLookup = global_event_handlers.Lookup;
const GlobalEventHandler = global_event_handlers.Handler;
var default_url = WebApiURL{ ._raw = "about:blank" };
pub var default_location: Location = Location{ ._url = &default_url };
@@ -105,16 +110,26 @@ _element_assigned_slots: Element.AssignedSlotLookup = .{},
/// Lazily-created inline event listeners (or listeners provided as attributes).
/// Avoids bloating all elements with extra function fields for rare usage.
///
/// Use this when a listener provided like these:
/// Use this when a listener provided like this:
///
/// ```js
/// img.onload = () => { ... };
/// ```
///
/// Its also used as cache for such cases after lazy evaluation:
///
/// ```html
/// <img onload="(() => { ... })()" />
/// ```
///
/// ```js
/// img.onload = () => { ... };
/// img.setAttribute("onload", "(() => { ... })()");
/// ```
_element_attr_listeners: Element.AttrListenerLookup = .{},
_element_attr_listeners: GlobalEventHandlersLookup = .{},
/// `load` events that'll be fired before window's `load` event.
/// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it.
_to_load: std.ArrayList(*Element) = .{},
_script_manager: ScriptManager,
@@ -171,6 +186,9 @@ url: [:0]const u8,
// If null the url must be used.
base_url: ?[:0]const u8,
// referer header cache.
referer_header: ?[:0]const u8,
// Arbitrary buffer. Need to temporarily lowercase a value? Use this. No lifetime
// guarantee - it's valid until someone else uses it.
buf: [BUF_SIZE]u8,
@@ -199,28 +217,26 @@ document: *Document,
// DOM version used to invalidate cached state of "live" collections
version: usize,
scheduler: Scheduler,
_req_id: ?usize = null,
_navigated_options: ?NavigatedOpts = null,
pub fn init(arena: Allocator, call_arena: Allocator, session: *Session) !*Page {
pub fn init(self: *Page, session: *Session) !void {
if (comptime IS_DEBUG) {
log.debug(.page, "page.init", .{});
}
const page = try session.browser.allocator.create(Page);
page._session = session;
const browser = session.browser;
self._session = session;
self.arena_pool = browser.arena_pool;
self.arena = browser.page_arena.allocator();
self.call_arena = browser.call_arena.allocator();
page.arena = arena;
page.call_arena = call_arena;
page.arena_pool = session.browser.arena_pool;
if (comptime IS_DEBUG) {
page._arena_pool_leak_track = .empty;
self._arena_pool_leak_track = .empty;
}
try page.reset(true);
return page;
try self.reset(true);
}
pub fn deinit(self: *Page) void {
@@ -234,17 +250,8 @@ pub fn deinit(self: *Page) void {
// stats.print(&stream) catch unreachable;
}
{
// some MicroTasks might be referencing the page, we need to drain it while
// the page still exists
var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
}
const session = self._session;
session.executor.removeContext();
session.browser.env.destroyContext(self.js);
self._script_manager.shutdown = true;
session.browser.http_client.abort();
@@ -258,46 +265,49 @@ pub fn deinit(self: *Page) void {
}
}
}
session.browser.allocator.destroy(self);
}
fn reset(self: *Page, comptime initializing: bool) !void {
const browser = self._session.browser;
if (comptime initializing == false) {
self._session.executor.removeContext();
// removing a context can trigger finalizers, so we can only check for
// a leak after the above.
if (comptime IS_DEBUG) {
var it = self._arena_pool_leak_track.valueIterator();
while (it.next()) |value_ptr| {
log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.* });
}
self._arena_pool_leak_track.clearRetainingCapacity();
}
browser.env.destroyContext(self.js);
// We force a garbage collection between page navigations to keep v8
// memory usage as low as possible.
self._session.browser.env.memoryPressureNotification(.moderate);
browser.env.memoryPressureNotification(.moderate);
self._script_manager.shutdown = true;
self._session.browser.http_client.abort();
browser.http_client.abort();
self._script_manager.deinit();
_ = self._session.browser.page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
// destroying the context, and aborting the http_client can both cause
// resources to be freed. We need to check for a leak after we've finished
// all of our cleanup.
if (comptime IS_DEBUG) {
var it = self._arena_pool_leak_track.valueIterator();
while (it.next()) |value_ptr| {
if (value_ptr.count > 0) {
log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner });
}
}
self._arena_pool_leak_track = .empty;
}
_ = browser.page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
}
self._factory = Factory.init(self);
self.scheduler = Scheduler.init(self.arena);
self.version = 0;
self.url = "about:blank";
self.base_url = null;
self.referer_header = null;
self.document = (try self._factory.document(Node.Document.HTMLDocument{ ._proto = undefined })).asDocument();
const storage_bucket = try self._factory.create(storage.Bucket{});
const screen = try Screen.init(self);
const visual_viewport = try VisualViewport.init(self);
self.window = try self._factory.eventTarget(Window{
._document = self.document,
._storage_bucket = storage_bucket,
@@ -305,6 +315,7 @@ fn reset(self: *Page, comptime initializing: bool) !void {
._proto = undefined,
._location = &default_location,
._screen = screen,
._visual_viewport = visual_viewport,
});
self.window._document = self.document;
self.window._location = &default_location;
@@ -320,7 +331,7 @@ fn reset(self: *Page, comptime initializing: bool) !void {
self._script_manager = ScriptManager.init(self);
errdefer self._script_manager.deinit();
self.js = try self._session.executor.createContext(self, true);
self.js = try browser.env.createContext(self, true);
errdefer self.js.deinit();
self._element_styles = .{};
@@ -333,6 +344,8 @@ fn reset(self: *Page, comptime initializing: bool) !void {
self._element_attr_listeners = .{};
self._to_load = .{};
self._notified_network_idle = .init;
self._notified_network_almost_idle = .init;
@@ -369,7 +382,7 @@ fn registerBackgroundTasks(self: *Page) !void {
const Browser = @import("Browser.zig");
try self.scheduler.add(self._session.browser, struct {
try self.js.scheduler.add(self._session.browser, struct {
fn runMessageLoop(ctx: *anyopaque) !?u32 {
const b: *Browser = @ptrCast(@alignCast(ctx));
b.runMessageLoop();
@@ -389,6 +402,32 @@ pub fn getOrigin(self: *Page, allocator: Allocator) !?[]const u8 {
return try URL.getOrigin(allocator, self.url);
}
// Add comon headers for a request:
// * cookies
// * referer
pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *Http.Headers) !void {
try self.requestCookie(.{}).headersForRequest(temp, url, headers);
// Build the referer
const referer = blk: {
if (self.referer_header == null) {
// build the cache
if (std.mem.startsWith(u8, self.url, "http")) {
self.referer_header = try std.mem.concatWithSentinel(self.arena, u8, &.{ "Referer: ", self.url }, 0);
} else {
self.referer_header = "";
}
}
break :blk self.referer_header.?;
};
// If the referer is empty, ignore the header.
if (referer.len > 0) {
try headers.add(referer);
}
}
const GetArenaOpts = struct {
debug: []const u8,
};
@@ -450,14 +489,22 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
// This assumption may be false when CDP Page.addScriptToEvaluateOnNewDocument is implemented
self.documentIsComplete();
self._session.browser.notification.dispatch(.page_navigate, &.{
self._session.notification.dispatch(.page_navigate, &.{
.req_id = req_id,
.opts = opts,
.url = request_url,
.timestamp = timestamp(.monotonic),
});
self._session.browser.notification.dispatch(.page_navigated, &.{
// Record telemetry for navigation
self._session.browser.app.telemetry.record(.{
.navigate = .{
.tls = false, // about:blank is not TLS
.proxy = self._session.browser.app.config.httpProxy() != null,
},
});
self._session.notification.dispatch(.page_navigated, &.{
.req_id = req_id,
.opts = .{
.cdp_id = opts.cdp_id,
@@ -492,13 +539,19 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
// We dispatch page_navigate event before sending the request.
// It ensures the event page_navigated is not dispatched before this one.
self._session.browser.notification.dispatch(.page_navigate, &.{
self._session.notification.dispatch(.page_navigate, &.{
.req_id = req_id,
.opts = opts,
.url = self.url,
.timestamp = timestamp(.monotonic),
});
// Record telemetry for navigation
self._session.browser.app.telemetry.record(.{ .navigate = .{
.tls = std.ascii.startsWithIgnoreCase(self.url, "https://"),
.proxy = self._session.browser.app.config.httpProxy() != null,
} });
session.navigation._current_navigation_kind = opts.kind;
http_client.request(.{
@@ -509,6 +562,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
.body = opts.body,
.cookie_jar = &self._session.cookie_jar,
.resource_type = .document,
.notification = self._session.notification,
.header_callback = pageHeaderDoneCallback,
.data_callback = pageDataCallback,
.done_callback = pageDoneCallback,
@@ -526,12 +580,6 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
// specifically for this type of lifetime.
pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOpts, priority: NavigationPriority) !void {
if (self.canScheduleNavigation(priority) == false) {
if (comptime IS_DEBUG) {
log.debug(.browser, "ignored navigation", .{
.target = request_url,
.reason = opts.reason,
});
}
return;
}
@@ -608,7 +656,8 @@ pub fn documentIsLoaded(self: *Page) void {
}
pub fn _documentIsLoaded(self: *Page) !void {
const event = try Event.initTrusted("DOMContentLoaded", .{ .bubbles = true }, self);
const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self);
defer if (!event._v8_handoff) event.deinit(false);
try self._event_manager.dispatch(
self.document.asEventTarget(),
event,
@@ -642,7 +691,7 @@ pub fn documentIsComplete(self: *Page) void {
std.debug.assert(self._navigated_options != null);
}
self._session.browser.notification.dispatch(.page_navigated, &.{
self._session.notification.dispatch(.page_navigated, &.{
.req_id = self._req_id.?,
.opts = self._navigated_options.?,
.url = self.url,
@@ -653,15 +702,37 @@ pub fn documentIsComplete(self: *Page) void {
fn _documentIsComplete(self: *Page) !void {
self.document._ready_state = .complete;
// dispatch window.load event
const event = try Event.initTrusted("load", .{}, self);
// this event is weird, it's dispatched directly on the window, but
// with the document as the target
var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls);
defer ls.deinit();
// Dispatch `_to_load` events before window.load.
for (self._to_load.items) |element| {
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
defer if (!event._v8_handoff) event.deinit(false);
// Dispatch inline event.
blk: {
const html_element = element.is(HtmlElement) orelse break :blk;
const listener = (try html_element.getOnLoad(self)) orelse break :blk;
ls.toLocal(listener).call(void, .{}) catch |err| {
log.warn(.event, "inline load event", .{ .element = element, .err = err });
};
}
// Dispatch events registered to event manager.
try self._event_manager.dispatch(element.asEventTarget(), event);
}
// `_to_load` can be cleaned here.
self._to_load.clearAndFree(self.arena);
// Dispatch window.load event.
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
defer if (!event._v8_handoff) event.deinit(false);
// This event is weird, it's dispatched directly on the window, but
// with the document as the target.
event._target = self.document.asEventTarget();
try self._event_manager.dispatchWithFunction(
self.window.asEventTarget(),
@@ -670,10 +741,11 @@ fn _documentIsComplete(self: *Page) !void {
.{ .inject_target = false, .context = "page load" },
);
const pageshow_event = try PageTransitionEvent.initTrusted("pageshow", .{}, self);
const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent();
defer if (!pageshow_event._v8_handoff) pageshow_event.deinit(false);
try self._event_manager.dispatchWithFunction(
self.window.asEventTarget(),
pageshow_event.asEvent(),
pageshow_event,
ls.toLocal(self.window._on_pageshow),
.{ .context = "page show" },
);
@@ -720,7 +792,7 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void {
switch (mime.content_type) {
.text_html => self._parse_state = .{ .html = .{} },
.application_json, .text_javascript, .text_css, .text_plain => {
var arr: std.ArrayListUnmanaged(u8) = .empty;
var arr: std.ArrayList(u8) = .empty;
try arr.appendSlice(self.arena, "<html><head><meta charset=\"utf-8\"></head><body><pre>");
self._parse_state = .{ .text = arr };
},
@@ -855,8 +927,8 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult {
var timer = try std.time.Timer.start();
var ms_remaining = wait_ms;
var scheduler = &self.scheduler;
var http_client = self._session.browser.http_client;
const browser = self._session.browser;
var http_client = browser.http_client;
// I'd like the page to know NOTHING about cdp_socket / CDP, but the
// fact is that the behavior of wait changes depending on whether or
@@ -900,7 +972,7 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult {
},
.html, .complete => {
if (self._queued_navigation != null) {
return .navigate;
return .done;
}
// The HTML page was parsed. We now either have JS scripts to
@@ -909,7 +981,7 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult {
// scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use
// it AFTER.
const ms_to_next_task = try scheduler.run();
const ms_to_next_task = try browser.runMacrotasks();
const http_active = http_client.active;
const total_network_activity = http_active + http_client.intercepted;
@@ -1045,16 +1117,16 @@ fn printWaitAnalysis(self: *Page) void {
const now = milliTimestamp(.monotonic);
{
std.debug.print("\nhigh_priority schedule: {d}\n", .{self.scheduler.high_priority.count()});
var it = self.scheduler.high_priority.iterator();
std.debug.print("\nhigh_priority schedule: {d}\n", .{self.js.scheduler.high_priority.count()});
var it = self.js.scheduler.high_priority.iterator();
while (it.next()) |task| {
std.debug.print(" - {s} schedule: {d}ms\n", .{ task.name, task.run_at - now });
}
}
{
std.debug.print("\nlow_priority schedule: {d}\n", .{self.scheduler.low_priority.count()});
var it = self.scheduler.low_priority.iterator();
std.debug.print("\nlow_priority schedule: {d}\n", .{self.js.scheduler.low_priority.count()});
var it = self.js.scheduler.low_priority.iterator();
while (it.next()) |task| {
std.debug.print(" - {s} schedule: {d}ms\n", .{ task.name, task.run_at - now });
}
@@ -1172,7 +1244,7 @@ pub fn getElementByIdFromNode(self: *Page, node: *Node, id: []const u8) ?*Elemen
pub fn setAttrListener(
self: *Page,
element: *Element,
listener_type: Element.KnownListener,
listener_type: GlobalEventHandler,
listener_callback: JS.Function.Global,
) !void {
if (comptime IS_DEBUG) {
@@ -1182,7 +1254,7 @@ pub fn setAttrListener(
});
}
const key = element.calcAttrListenerKey(listener_type);
const key = global_event_handlers.calculateKey(element.asEventTarget(), listener_type);
const gop = try self._element_attr_listeners.getOrPut(self.arena, key);
gop.value_ptr.* = listener_callback;
}
@@ -1191,9 +1263,10 @@ pub fn setAttrListener(
pub fn getAttrListener(
self: *const Page,
element: *Element,
listener_type: Element.KnownListener,
listener_type: GlobalEventHandler,
) ?JS.Function.Global {
return self._element_attr_listeners.get(element.calcAttrListenerKey(listener_type));
const key = global_event_handlers.calculateKey(element.asEventTarget(), listener_type);
return self._element_attr_listeners.get(key);
}
pub fn registerPerformanceObserver(self: *Page, observer: *PerformanceObserver) !void {
@@ -1226,7 +1299,7 @@ pub fn notifyPerformanceObservers(self: *Page, entry: *Performance.Entry) !void
}
self._performance_delivery_scheduled = true;
return self.scheduler.add(
return self.js.scheduler.add(
self,
struct {
fn run(_page: *anyopaque) anyerror!?u32 {
@@ -1378,10 +1451,12 @@ pub fn deliverSlotchangeEvents(self: *Page) void {
self._slots_pending_slotchange.clearRetainingCapacity();
for (slots) |slot| {
const event = Event.initTrusted("slotchange", .{ .bubbles = true }, self) catch |err| {
const event = Event.initTrusted(comptime .wrap("slotchange"), .{ .bubbles = true }, self) catch |err| {
log.err(.page, "deliverSlotchange.init", .{ .err = err });
continue;
};
defer if (!event._v8_handoff) event.deinit(false);
const target = slot.asNode().asEventTarget();
_ = target.dispatchEvent(event, self) catch |err| {
log.err(.page, "deliverSlotchange.dispatch", .{ .err = err });
@@ -1391,14 +1466,14 @@ pub fn deliverSlotchangeEvents(self: *Page) void {
fn notifyNetworkIdle(self: *Page) void {
lp.assert(self._notified_network_idle == .done, "Page.notifyNetworkIdle", .{});
self._session.browser.notification.dispatch(.page_network_idle, &.{
self._session.notification.dispatch(.page_network_idle, &.{
.timestamp = timestamp(.monotonic),
});
}
fn notifyNetworkAlmostIdle(self: *Page) void {
lp.assert(self._notified_network_almost_idle == .done, "Page.notifyNetworkAlmostIdle", .{});
self._session.browser.notification.dispatch(.page_network_almost_idle, &.{
self._session.notification.dispatch(.page_network_almost_idle, &.{
.timestamp = timestamp(.monotonic),
});
}
@@ -2034,6 +2109,12 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "address", .{}) catch unreachable, ._tag = .address },
),
asUint("picture") => return self.createHtmlElementT(
Element.Html.Picture,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
else => {},
},
8 => switch (@as(u64, @bitCast(name[0..8].*))) {
@@ -2235,236 +2316,6 @@ fn populateElementAttributes(self: *Page, element: *Element, list: anytype) !voi
}
var attributes = try element.createAttributeList(self);
while (list.next()) |attr| {
// Event handlers can be provided like attributes; here we check if there's such.
const name = attr.name.local;
lp.assert(name.len != 0, "populateElementAttributes: 0-length attr name", .{ .attr = attr });
// Idea here is to make this check as cheap as possible.
const has_on_prefix = @as(u16, @bitCast([2]u8{ name.ptr[0], name.ptr[1 % name.len] })) == asUint("on");
// We may have found an event handler.
if (has_on_prefix) {
// Must be usable as function.
const func = self.js.stringToPersistedFunction(attr.value.slice()) catch continue;
// Longest known listener kind is 32 bytes long.
const remaining: u6 = @truncate(name.len -| 2);
const unsafe = name.ptr + 2;
const Vec16x8 = @Vector(16, u8);
const Vec32x8 = @Vector(32, u8);
switch (remaining) {
3 => if (@as(u24, @bitCast(unsafe[0..3].*)) == asUint("cut")) {
try self.setAttrListener(element, .cut, func);
},
4 => switch (@as(u32, @bitCast(unsafe[0..4].*))) {
asUint("blur") => try self.setAttrListener(element, .blur, func),
asUint("copy") => try self.setAttrListener(element, .copy, func),
asUint("drag") => try self.setAttrListener(element, .drag, func),
asUint("drop") => try self.setAttrListener(element, .drop, func),
asUint("load") => try self.setAttrListener(element, .load, func),
asUint("play") => try self.setAttrListener(element, .play, func),
else => {},
},
5 => switch (@as(u40, @bitCast(unsafe[0..5].*))) {
asUint("abort") => try self.setAttrListener(element, .abort, func),
asUint("click") => try self.setAttrListener(element, .click, func),
asUint("close") => try self.setAttrListener(element, .close, func),
asUint("ended") => try self.setAttrListener(element, .ended, func),
asUint("error") => try self.setAttrListener(element, .@"error", func),
asUint("focus") => try self.setAttrListener(element, .focus, func),
asUint("input") => try self.setAttrListener(element, .input, func),
asUint("keyup") => try self.setAttrListener(element, .keyup, func),
asUint("paste") => try self.setAttrListener(element, .paste, func),
asUint("pause") => try self.setAttrListener(element, .pause, func),
asUint("reset") => try self.setAttrListener(element, .reset, func),
asUint("wheel") => try self.setAttrListener(element, .wheel, func),
else => {},
},
6 => switch (@as(u48, @bitCast(unsafe[0..6].*))) {
asUint("cancel") => try self.setAttrListener(element, .cancel, func),
asUint("change") => try self.setAttrListener(element, .change, func),
asUint("resize") => try self.setAttrListener(element, .resize, func),
asUint("scroll") => try self.setAttrListener(element, .scroll, func),
asUint("seeked") => try self.setAttrListener(element, .seeked, func),
asUint("select") => try self.setAttrListener(element, .select, func),
asUint("submit") => try self.setAttrListener(element, .submit, func),
asUint("toggle") => try self.setAttrListener(element, .toggle, func),
else => {},
},
7 => switch (@as(u56, @bitCast(unsafe[0..7].*))) {
asUint("canplay") => try self.setAttrListener(element, .canplay, func),
asUint("command") => try self.setAttrListener(element, .command, func),
asUint("dragend") => try self.setAttrListener(element, .dragend, func),
asUint("emptied") => try self.setAttrListener(element, .emptied, func),
asUint("invalid") => try self.setAttrListener(element, .invalid, func),
asUint("keydown") => try self.setAttrListener(element, .keydown, func),
asUint("mouseup") => try self.setAttrListener(element, .mouseup, func),
asUint("playing") => try self.setAttrListener(element, .playing, func),
asUint("seeking") => try self.setAttrListener(element, .seeking, func),
asUint("stalled") => try self.setAttrListener(element, .stalled, func),
asUint("suspend") => try self.setAttrListener(element, .@"suspend", func),
asUint("waiting") => try self.setAttrListener(element, .waiting, func),
else => {},
},
8 => switch (@as(u64, @bitCast(unsafe[0..8].*))) {
asUint("auxclick") => try self.setAttrListener(element, .auxclick, func),
asUint("dblclick") => try self.setAttrListener(element, .dblclick, func),
asUint("dragexit") => try self.setAttrListener(element, .dragexit, func),
asUint("dragover") => try self.setAttrListener(element, .dragover, func),
asUint("formdata") => try self.setAttrListener(element, .formdata, func),
asUint("keypress") => try self.setAttrListener(element, .keypress, func),
asUint("mouseout") => try self.setAttrListener(element, .mouseout, func),
asUint("progress") => try self.setAttrListener(element, .progress, func),
else => {},
},
// Won't fit to 64-bit integer; we do 2 checks.
9 => switch (@as(u64, @bitCast(unsafe[0..8].*))) {
asUint("cuechang") => if (unsafe[8] == 'e') try self.setAttrListener(element, .cuechange, func),
asUint("dragente") => if (unsafe[8] == 'r') try self.setAttrListener(element, .dragenter, func),
asUint("dragleav") => if (unsafe[8] == 'e') try self.setAttrListener(element, .dragleave, func),
asUint("dragstar") => if (unsafe[8] == 't') try self.setAttrListener(element, .dragstart, func),
asUint("loadstar") => if (unsafe[8] == 't') try self.setAttrListener(element, .loadstart, func),
asUint("mousedow") => if (unsafe[8] == 'n') try self.setAttrListener(element, .mousedown, func),
asUint("mousemov") => if (unsafe[8] == 'e') try self.setAttrListener(element, .mousemove, func),
asUint("mouseove") => if (unsafe[8] == 'r') try self.setAttrListener(element, .mouseover, func),
asUint("pointeru") => if (unsafe[8] == 'p') try self.setAttrListener(element, .pointerup, func),
asUint("scrollen") => if (unsafe[8] == 'd') try self.setAttrListener(element, .scrollend, func),
else => {},
},
10 => switch (@as(u64, @bitCast(unsafe[0..8].*))) {
asUint("loadedda") => if (asUint("ta") == @as(u16, @bitCast(unsafe[8..10].*)))
try self.setAttrListener(element, .loadeddata, func),
asUint("pointero") => if (asUint("ut") == @as(u16, @bitCast(unsafe[8..10].*)))
try self.setAttrListener(element, .pointerout, func),
asUint("ratechan") => if (asUint("ge") == @as(u16, @bitCast(unsafe[8..10].*)))
try self.setAttrListener(element, .ratechange, func),
asUint("slotchan") => if (asUint("ge") == @as(u16, @bitCast(unsafe[8..10].*)))
try self.setAttrListener(element, .slotchange, func),
asUint("timeupda") => if (asUint("te") == @as(u16, @bitCast(unsafe[8..10].*)))
try self.setAttrListener(element, .timeupdate, func),
else => {},
},
11 => switch (@as(u64, @bitCast(unsafe[0..8].*))) {
asUint("beforein") => if (asUint("put") == @as(u24, @bitCast(unsafe[8..11].*)))
try self.setAttrListener(element, .beforeinput, func),
asUint("beforema") => if (asUint("tch") == @as(u24, @bitCast(unsafe[8..11].*)))
try self.setAttrListener(element, .beforematch, func),
asUint("contextl") => if (asUint("ost") == @as(u24, @bitCast(unsafe[8..11].*)))
try self.setAttrListener(element, .contextlost, func),
asUint("contextm") => if (asUint("enu") == @as(u24, @bitCast(unsafe[8..11].*)))
try self.setAttrListener(element, .contextmenu, func),
asUint("pointerd") => if (asUint("own") == @as(u24, @bitCast(unsafe[8..11].*)))
try self.setAttrListener(element, .pointerdown, func),
asUint("pointerm") => if (asUint("ove") == @as(u24, @bitCast(unsafe[8..11].*)))
try self.setAttrListener(element, .pointermove, func),
asUint("pointero") => if (asUint("ver") == @as(u24, @bitCast(unsafe[8..11].*)))
try self.setAttrListener(element, .pointerover, func),
asUint("selectst") => if (asUint("art") == @as(u24, @bitCast(unsafe[8..11].*)))
try self.setAttrListener(element, .selectstart, func),
else => {},
},
12 => switch (@as(u64, @bitCast(unsafe[0..8].*))) {
asUint("animatio") => if (asUint("nend") == @as(u32, @bitCast(unsafe[8..12].*)))
try self.setAttrListener(element, .animationend, func),
asUint("beforeto") => if (asUint("ggle") == @as(u32, @bitCast(unsafe[8..12].*)))
try self.setAttrListener(element, .beforetoggle, func),
asUint("pointere") => if (asUint("nter") == @as(u32, @bitCast(unsafe[8..12].*)))
try self.setAttrListener(element, .pointerenter, func),
asUint("pointerl") => if (asUint("eave") == @as(u32, @bitCast(unsafe[8..12].*)))
try self.setAttrListener(element, .pointerleave, func),
asUint("volumech") => if (asUint("ange") == @as(u32, @bitCast(unsafe[8..12].*)))
try self.setAttrListener(element, .volumechange, func),
else => {},
},
13 => switch (@as(u64, @bitCast(unsafe[0..8].*))) {
asUint("pointerc") => if (asUint("ancel") == @as(u40, @bitCast(unsafe[8..13].*)))
try self.setAttrListener(element, .pointercancel, func),
asUint("transiti") => switch (@as(u40, @bitCast(unsafe[8..13].*))) {
asUint("onend") => try self.setAttrListener(element, .transitionend, func),
asUint("onrun") => try self.setAttrListener(element, .transitionrun, func),
else => {},
},
else => {},
},
14 => switch (@as(u64, @bitCast(unsafe[0..8].*))) {
asUint("animatio") => if (asUint("nstart") == @as(u48, @bitCast(unsafe[8..14].*)))
try self.setAttrListener(element, .animationstart, func),
asUint("canplayt") => if (asUint("hrough") == @as(u48, @bitCast(unsafe[8..14].*)))
try self.setAttrListener(element, .canplaythrough, func),
asUint("duration") => if (asUint("change") == @as(u48, @bitCast(unsafe[8..14].*)))
try self.setAttrListener(element, .durationchange, func),
asUint("loadedme") => if (asUint("tadata") == @as(u48, @bitCast(unsafe[8..14].*)))
try self.setAttrListener(element, .loadedmetadata, func),
else => {},
},
15 => switch (@as(u64, @bitCast(unsafe[0..8].*))) {
asUint("animatio") => if (asUint("ncancel") == @as(u56, @bitCast(unsafe[8..15].*)))
try self.setAttrListener(element, .animationcancel, func),
asUint("contextr") => if (asUint("estored") == @as(u56, @bitCast(unsafe[8..15].*)))
try self.setAttrListener(element, .contextrestored, func),
asUint("fullscre") => if (asUint("enerror") == @as(u56, @bitCast(unsafe[8..15].*)))
try self.setAttrListener(element, .fullscreenerror, func),
asUint("selectio") => if (asUint("nchange") == @as(u56, @bitCast(unsafe[8..15].*)))
try self.setAttrListener(element, .selectionchange, func),
asUint("transiti") => if (asUint("onstart") == @as(u56, @bitCast(unsafe[8..15].*)))
try self.setAttrListener(element, .transitionstart, func),
else => {},
},
// Can't switch on vector types.
16 => {
const as_vector: Vec16x8 = unsafe[0..16].*;
if (@reduce(.And, as_vector == @as(Vec16x8, "fullscreenchange".*))) {
try self.setAttrListener(element, .fullscreenchange, func);
} else if (@reduce(.And, as_vector == @as(Vec16x8, "pointerrawupdate".*))) {
try self.setAttrListener(element, .pointerrawupdate, func);
} else if (@reduce(.And, as_vector == @as(Vec16x8, "transitioncancel".*))) {
try self.setAttrListener(element, .transitioncancel, func);
}
},
17 => {
const as_vector: Vec16x8 = unsafe[0..16].*;
const dirty = @reduce(.And, as_vector == @as(Vec16x8, "gotpointercaptur".*)) and
unsafe[16] == 'e';
if (dirty) {
try self.setAttrListener(element, .gotpointercapture, func);
}
},
18 => {
const as_vector: Vec16x8 = unsafe[0..16].*;
const is_animationiteration = @reduce(.And, as_vector == @as(Vec16x8, "animationiterati".*)) and
asUint("on") == @as(u16, @bitCast(unsafe[16..18].*));
if (is_animationiteration) {
try self.setAttrListener(element, .animationiteration, func);
} else {
const is_lostpointercapture = @reduce(.And, as_vector == @as(Vec16x8, "lostpointercaptu".*)) and
asUint("re") == @as(u16, @bitCast(unsafe[16..18].*));
if (is_lostpointercapture) {
try self.setAttrListener(element, .lostpointercapture, func);
}
}
},
23 => {
const as_vector: Vec16x8 = unsafe[0..16].*;
const dirty = @reduce(.And, as_vector == @as(Vec16x8, "securitypolicyvi".*)) and
asUint("olation") == @as(u56, @bitCast(unsafe[16..23].*));
if (dirty) {
try self.setAttrListener(element, .securitypolicyviolation, func);
}
},
32 => {
const as_vector: Vec32x8 = unsafe[0..32].*;
if (@reduce(.And, as_vector == @as(Vec32x8, "contentvisibilityautostatechange".*))) {
try self.setAttrListener(element, .contentvisibilityautostatechange, func);
}
},
else => {},
}
}
try attributes.putNew(attr.name.local.slice(), attr.value.slice(), self);
}
}
@@ -3058,9 +2909,9 @@ const ParseState = union(enum) {
pre,
complete,
err: anyerror,
html: std.ArrayListUnmanaged(u8),
text: std.ArrayListUnmanaged(u8),
raw: std.ArrayListUnmanaged(u8),
html: std.ArrayList(u8),
text: std.ArrayList(u8),
raw: std.ArrayList(u8),
raw_done: []const u8,
};
@@ -3197,14 +3048,16 @@ pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void {
.y = y,
});
}
const event = try @import("webapi/event/MouseEvent.zig").init("click", .{
const event = (try @import("webapi/event/MouseEvent.zig").init("click", .{
.bubbles = true,
.cancelable = true,
.composed = true,
.clientX = x,
.clientY = y,
}, self);
try self._event_manager.dispatch(target.asEventTarget(), event.asEvent());
}, self)).asEvent();
defer if (!event._v8_handoff) event.deinit(false);
try self._event_manager.dispatch(target.asEventTarget(), event);
}
// callback when the "click" event reaches the pages.
@@ -3242,12 +3095,12 @@ pub fn handleClick(self: *Page, target: *Node) !void {
}, .anchor);
},
.input => |input| switch (input._input_type) {
.submit => return self.submitForm(element, input.getForm(self)),
.submit => return self.submitForm(element, input.getForm(self), .{}),
else => self.window._document._active_element = element,
},
.button => |button| {
if (std.mem.eql(u8, button.getType(), "submit")) {
return self.submitForm(element, button.getForm(self));
return self.submitForm(element, button.getForm(self), .{});
}
},
.select, .textarea => self.window._document._active_element = element,
@@ -3256,6 +3109,9 @@ pub fn handleClick(self: *Page, target: *Node) !void {
}
pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void {
const event = keyboard_event.asEvent();
defer if (!event._v8_handoff) event.deinit(false);
const element = self.window._document._active_element orelse return;
if (comptime IS_DEBUG) {
log.debug(.page, "page keydown", .{
@@ -3264,7 +3120,7 @@ pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void {
.key = keyboard_event._key,
});
}
try self._event_manager.dispatch(element.asEventTarget(), keyboard_event.asEvent());
try self._event_manager.dispatch(element.asEventTarget(), event);
}
pub fn handleKeydown(self: *Page, target: *Node, event: *Event) !void {
@@ -3277,7 +3133,7 @@ pub fn handleKeydown(self: *Page, target: *Node, event: *Event) !void {
if (target.is(Element.Html.Input)) |input| {
if (key == .Enter) {
return self.submitForm(input.asElement(), input.getForm(self));
return self.submitForm(input.asElement(), input.getForm(self), .{});
}
// Don't handle text input for radio/checkbox
@@ -3307,7 +3163,10 @@ pub fn handleKeydown(self: *Page, target: *Node, event: *Event) !void {
}
}
pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form) !void {
const SubmitFormOpts = struct {
fire_event: bool = true,
};
pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form, submit_opts: SubmitFormOpts) !void {
const form = form_ orelse return;
if (submitter_) |submitter| {
@@ -3315,8 +3174,36 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form
return;
}
}
if (self.canScheduleNavigation(.form) == false) {
return;
}
const form_element = form.asElement();
if (submit_opts.fire_event) {
const submit_event = try Event.initTrusted(comptime .wrap("submit"), .{ .bubbles = true, .cancelable = true }, self);
defer if (!submit_event._v8_handoff) submit_event.deinit(false);
const onsubmit_handler = try form.asHtmlElement().getOnSubmit(self);
var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls);
defer ls.deinit();
try self._event_manager.dispatchWithFunction(
form_element.asEventTarget(),
submit_event,
ls.toLocal(onsubmit_handler),
.{ .context = "form submit" },
);
// If the submit event was prevented, don't submit the form
if (submit_event._prevent_default) {
return;
}
}
const FormData = @import("webapi/net/FormData.zig");
// The submitter can be an input box (if enter was entered on the box)
// I don't think this is technically correct, but FormData handles it ok

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:
@@ -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,
@@ -357,9 +361,6 @@ 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);
@@ -377,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,
@@ -452,9 +454,6 @@ 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);
@@ -480,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,
@@ -634,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) {
@@ -684,10 +684,6 @@ 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| {
@@ -849,7 +845,7 @@ pub const Script = struct {
defer {
// We should run microtasks even if script execution fails.
local.runMicrotasks();
_ = page.scheduler.run() catch |err| {
_ = page.js.scheduler.run() catch |err| {
log.err(.page, "scheduler", .{ .err = err });
};
}
@@ -873,7 +869,7 @@ pub const Script = struct {
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,
@@ -881,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 {
@@ -904,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 {
@@ -929,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 .{};
@@ -941,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 {
@@ -503,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"));
@@ -779,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

@@ -23,12 +23,10 @@ const string = @import("../../string.zig");
const Page = @import("../Page.zig");
const js = @import("js.zig");
const bridge = @import("bridge.zig");
const Context = @import("Context.zig");
const TaggedOpaque = @import("TaggedOpaque.zig");
const v8 = js.v8;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const CALL_ARENA_RETAIN = 1024 * 16;
@@ -37,6 +35,7 @@ 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 {
@@ -55,7 +54,9 @@ pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void {
.isolate = .{ .handle = v8_isolate },
},
.prev_local = ctx.local,
.prev_context = ctx.page.js,
};
ctx.page.js = ctx;
ctx.local = &self.local;
}
@@ -81,10 +82,10 @@ pub fn deinit(self: *Caller) void {
ctx.call_depth = call_depth;
ctx.local = self.prev_local;
ctx.page.js = self.prev_context;
}
pub const CallOpts = struct {
cache: ?[]const u8 = null,
dom_exception: bool = false,
null_as_undefined: bool = false,
as_typed_array: bool = false,
@@ -314,14 +315,14 @@ fn isInErrorSet(err: anyerror, comptime T: type) bool {
}
fn nameToString(self: *const Caller, comptime T: type, name: *const v8.Name) !T {
const v8_string = @as(*const v8.String, @ptrCast(name));
const handle = @as(*const v8.String, @ptrCast(name));
if (T == string.String) {
return self.local.jsStringToStringSSO(v8_string, .{});
return js.String.toSSO(.{ .local = &self.local, .handle = handle }, false);
}
if (T == string.Global) {
return self.local.jsStringToStringSSO(v8_string, .{ .allocator = self.local.ctx.allocator });
return js.String.toSSO(.{ .local = &self.local, .handle = handle }, true);
}
return try self.local.valueHandleToString(v8_string, .{});
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 {
@@ -334,6 +335,7 @@ fn handleError(self: *Caller, comptime T: type, comptime F: type, err: anyerror,
}
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"),

View File

@@ -21,8 +21,9 @@ const lp = @import("lightpanda");
const log = @import("../../log.zig");
const js = @import("js.zig");
const Env = @import("Env.zig");
const bridge = @import("bridge.zig");
const TaggedOpaque = @import("TaggedOpaque.zig");
const Scheduler = @import("Scheduler.zig");
const Page = @import("../Page.zig");
const ScriptManager = @import("../ScriptManager.zig");
@@ -38,6 +39,7 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
const Context = @This();
id: usize,
env: *Env,
page: *Page,
isolate: js.Isolate,
@@ -82,7 +84,8 @@ identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Any type that is stored in the identity_map which has a finalizer declared
// will have its finalizer stored here. This is only used when shutting down
// if v8 hasn't called the finalizer directly itself.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, FinalizerCallback) = .empty,
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
finalizer_callback_pool: std.heap.MemoryPool(FinalizerCallback),
// Some web APIs have to manage opaque values. Ideally, they use an
// js.Object, but the js.Object has no lifetime guarantee beyond the
@@ -102,6 +105,7 @@ global_promise_resolvers: std.ArrayList(v8.Global) = .empty,
// Temp variants stored in HashMaps for O(1) early cleanup.
// Key is global.data_ptr.
global_values_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
global_promises_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
global_functions_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Our module cache: normalized module specifier => module.
@@ -117,6 +121,15 @@ module_identifier: std.AutoHashMapUnmanaged(u32, [:0]const u8) = .empty,
// the page's script manager
script_manager: ?*ScriptManager,
// Our macrotasks
scheduler: Scheduler,
// Prevents us from enqueuing a microtask for this context while we're shutting
// down.
shutting_down: bool = false,
unknown_properties: (if (IS_DEBUG) std.StringHashMapUnmanaged(UnknownPropertyStat) else void) = if (IS_DEBUG) .{} else {},
const ModuleEntry = struct {
// Can be null if we're asynchrously loading the module, in
// which case resolver_promise cannot be null.
@@ -149,6 +162,33 @@ pub fn fromIsolate(isolate: js.Isolate) *Context {
}
pub fn deinit(self: *Context) void {
if (comptime IS_DEBUG) {
var it = self.unknown_properties.iterator();
while (it.next()) |kv| {
log.debug(.unknown_prop, "unknown property", .{
.property = kv.key_ptr.*,
.occurrences = kv.value_ptr.count,
.first_stack = kv.value_ptr.first_stack,
});
}
}
defer self.env.app.arena_pool.release(self.arena);
var hs: js.HandleScope = undefined;
const entered = self.enter(&hs);
defer entered.exit();
// We might have microtasks in the isolate that refence this context. The
// only option we have is to run them. But a microtask could queue another
// microtask, so we set the shutting_down flag, so that any such microtask
// will be a noop (this isn't automatic, when v8 calls our microtask callback
// the first thing we'll check is if self.shutting_down == true).
self.shutting_down = true;
self.env.runMicrotasks();
// can release objects
self.scheduler.deinit();
{
var it = self.identity_map.valueIterator();
while (it.next()) |global| {
@@ -158,8 +198,9 @@ pub fn deinit(self: *Context) void {
{
var it = self.finalizer_callbacks.valueIterator();
while (it.next()) |finalizer| {
finalizer.deinit();
finalizer.*.deinit();
}
self.finalizer_callback_pool.deinit();
}
for (self.global_values.items) |*global| {
@@ -193,6 +234,13 @@ pub fn deinit(self: *Context) void {
}
}
{
var it = self.global_promises_temp.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
{
var it = self.global_functions_temp.valueIterator();
while (it.next()) |global| {
@@ -201,34 +249,44 @@ pub fn deinit(self: *Context) void {
}
if (self.entered) {
var ls: js.Local.Scope = undefined;
self.localScope(&ls);
defer ls.deinit();
v8.v8__Context__Exit(ls.local.handle);
v8.v8__Context__Exit(@ptrCast(v8.v8__Global__Get(&self.handle, self.isolate.handle)));
}
v8.v8__Global__Reset(&self.handle);
}
pub fn weakRef(self: *Context, obj: anytype) void {
const global = self.identity_map.getPtr(@intFromPtr(obj)) orelse {
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
}
return;
};
v8.v8__Global__SetWeakFinalizer(global, obj, bridge.Struct(@TypeOf(obj)).JsApi.Meta.finalizer.from_v8, v8.kParameter);
v8.v8__Global__SetWeakFinalizer(&fc.global, fc, bridge.Struct(@TypeOf(obj)).JsApi.Meta.finalizer.from_v8, v8.kParameter);
}
pub fn safeWeakRef(self: *Context, obj: anytype) void {
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
}
return;
};
v8.v8__Global__ClearWeak(&fc.global);
v8.v8__Global__SetWeakFinalizer(&fc.global, fc, bridge.Struct(@TypeOf(obj)).JsApi.Meta.finalizer.from_v8, v8.kParameter);
}
pub fn strongRef(self: *Context, obj: anytype) void {
const global = self.identity_map.getPtr(@intFromPtr(obj)) orelse {
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
}
return;
};
v8.v8__Global__ClearWeak(global);
v8.v8__Global__ClearWeak(&fc.global);
}
pub fn release(self: *Context, item: anytype) void {
@@ -246,17 +304,20 @@ pub fn release(self: *Context, item: anytype) void {
// The item has been fianalized, remove it for the finalizer callback so that
// we don't try to call it again on shutdown.
_ = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse {
const fc = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
}
return;
};
self.finalizer_callback_pool.destroy(fc.value);
return;
}
var map = switch (@TypeOf(item)) {
js.Value.Temp => &self.global_values_temp,
js.Promise.Temp => &self.global_promises_temp,
js.Function.Temp => &self.global_functions_temp,
else => |T| @compileError("Context.release cannot be called with a " ++ @typeName(T)),
};
@@ -320,7 +381,25 @@ pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local
if (cacheable) {
gop = try self.module_cache.getOrPut(arena, url);
if (gop.found_existing) {
if (gop.value_ptr.module != null) {
if (gop.value_ptr.module) |cache_mod| {
if (gop.value_ptr.module_promise == null) {
// This an usual case, but it can happen if a module is
// first asynchronously requested and then synchronously
// requested as a child of some root import. In that case,
// the module may not be instantiated yet (so we have to
// do that). It might not be evaluated yet. So we have
// to do that too. Evaluation is particularly important
// as it sets up our cache entry's module_promise.
// It appears that v8 handles potential double-instantiated
// and double-evaluated modules safely. The 2nd instantiation
// is a no-op, and the second evaluation returns the same
// promise.
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);
}
return if (comptime want_result) gop.value_ptr.* else {};
}
} else {
@@ -350,6 +429,10 @@ pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local
return error.ModuleInstantiationError;
}
return self.evaluateModule(want_result, mod, owned_url, cacheable);
}
fn evaluateModule(self: *Context, comptime want_result: bool, mod: js.Module, url: []const u8, cacheable: bool) !(if (want_result) ModuleEntry else void) {
const evaluated = mod.evaluate() catch {
if (comptime IS_DEBUG) {
std.debug.assert(mod.getStatus() == .kErrored);
@@ -357,9 +440,13 @@ pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local
// Some module-loading errors aren't handled by TryCatch. We need to
// get the error from the module itself.
const message = blk: {
const e = mod.getException().toString() catch break :blk "???";
break :blk e.toSlice() catch "???";
};
log.warn(.js, "evaluate module", .{
.specifier = owned_url,
.message = mod.getException().toString(.{}) catch "???",
.message = message,
.specifier = url,
});
return error.EvaluationError;
};
@@ -368,24 +455,15 @@ pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local
// Must be a promise that gets returned here.
lp.assert(evaluated.isPromise(), "Context.module non-promise", .{});
if (comptime !want_result) {
// avoid creating a bunch of persisted objects if it isn't
// cacheable and the caller doesn't care about results.
// This is pretty common, i.e. every <script type=module>
// within the html page.
if (!cacheable) {
return;
if (!cacheable) {
switch (comptime want_result) {
false => return,
true => unreachable,
}
}
// anyone who cares about the result, should also want it to
// be cached
if (comptime IS_DEBUG) {
std.debug.assert(cacheable);
}
// entry has to have been created atop this function
const entry = self.module_cache.getPtr(owned_url).?;
const entry = self.module_cache.getPtr(url).?;
// and the module must have been set after we compiled it
lp.assert(entry.module != null, "Context.module with module", .{});
@@ -457,11 +535,11 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: *
const request_len = requests.len();
const script_manager = self.script_manager.?;
for (0..request_len) |i| {
const specifier = try local.jsStringToZigZ(requests.get(i).specifier(), .{});
const specifier = requests.get(i).specifier(local);
const normalized_specifier = try script_manager.resolveSpecifier(
self.call_arena,
url,
specifier,
try specifier.toSliceZ(),
);
const nested_gop = try self.module_cache.getOrPut(self.arena, normalized_specifier);
if (!nested_gop.found_existing) {
@@ -494,14 +572,14 @@ fn resolveModuleCallback(
_ = import_attributes;
const self = fromC(c_context.?);
var local = js.Local{
const local = js.Local{
.ctx = self,
.handle = c_context.?,
.isolate = self.isolate,
.call_arena = self.call_arena,
};
const specifier = local.jsStringToZigZ(c_specifier.?, .{}) catch |err| {
const specifier = js.String.toSliceZ(.{ .local = &local, .handle = c_specifier.? }) catch |err| {
log.err(.js, "resolve module", .{ .err = err });
return null;
};
@@ -527,19 +605,19 @@ pub fn dynamicModuleCallback(
_ = import_attrs;
const self = fromC(c_context.?);
var local = js.Local{
const local = js.Local{
.ctx = self,
.handle = c_context.?,
.call_arena = self.call_arena,
.isolate = self.isolate,
};
const resource = local.jsStringToZigZ(resource_name.?, .{}) catch |err| {
const resource = js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| {
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback1" });
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
};
const specifier = local.jsStringToZigZ(v8_specifier.?, .{}) catch |err| {
const specifier = js.String.toSliceZ(.{ .local = &local, .handle = v8_specifier.? }) catch |err| {
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback2" });
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
};
@@ -684,6 +762,9 @@ fn _dynamicModuleCallback(self: *Context, specifier: [:0]const u8, referrer: []c
return promise;
}
// we might update the map, so we might need to re-fetch this.
var entry = gop.value_ptr;
// So we have a module, but no async resolver. This can only
// happen if the module was first synchronously loaded (Does that
// ever even happen?!) You'd think we can just return the module
@@ -696,7 +777,7 @@ fn _dynamicModuleCallback(self: *Context, specifier: [:0]const u8, referrer: []c
// If the module hasn't been evaluated yet (it was only instantiated
// as a static import dependency), we need to evaluate it now.
if (gop.value_ptr.module_promise == null) {
if (entry.module_promise == null) {
const mod = local.toLocal(gop.value_ptr.module.?);
const status = mod.getStatus();
if (status == .kEvaluated or status == .kEvaluating) {
@@ -705,7 +786,7 @@ fn _dynamicModuleCallback(self: *Context, specifier: [:0]const u8, referrer: []c
const module_resolver = local.createPromiseResolver();
module_resolver.resolve("resolve module", mod.getModuleNamespace());
_ = try module_resolver.persist();
gop.value_ptr.module_promise = try module_resolver.promise().persist();
entry.module_promise = try module_resolver.promise().persist();
} else {
// the module was loaded, but not evaluated, we _have_ to evaluate it now
const evaluated = mod.evaluate() catch {
@@ -716,18 +797,20 @@ fn _dynamicModuleCallback(self: *Context, specifier: [:0]const u8, referrer: []c
return promise;
};
lp.assert(evaluated.isPromise(), "Context._dynamicModuleCallback non-promise", .{});
gop.value_ptr.module_promise = try evaluated.toPromise().persist();
// mod.evaluate can invalidate or gop
entry = self.module_cache.getPtr(specifier).?;
entry.module_promise = try evaluated.toPromise().persist();
}
}
// like before, we want to set this up so that if anything else
// tries to load this module, it can just return our promise
// since we're going to be doing all the work.
gop.value_ptr.resolver_promise = try promise.persist();
entry.resolver_promise = try promise.persist();
// But we can skip direclty to `resolveDynamicModule` which is
// what the above callback will eventually do.
self.resolveDynamicModule(state, gop.value_ptr.*, local);
self.resolveDynamicModule(state, entry.*, local);
return promise;
}
@@ -844,70 +927,121 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul
};
}
// Microtasks
// Used to make temporarily enter and exit a context, updating and restoring
// page.js:
// var hs: js.HandleScope = undefined;
// const entered = ctx.enter(&hs);
// defer entered.exit();
pub fn enter(self: *Context, hs: *js.HandleScope) Entered {
const isolate = self.isolate;
js.HandleScope.init(hs, isolate);
const page = self.page;
const original = page.js;
page.js = self;
const handle: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle));
v8.v8__Context__Enter(handle);
return .{ .original = original, .handle = handle, .handle_scope = hs };
}
const Entered = struct {
// the context we should restore on the page
original: *Context,
// the handle of the entered context
handle: *const v8.Context,
handle_scope: *js.HandleScope,
pub fn exit(self: Entered) void {
self.original.page.js = self.original;
v8.v8__Context__Exit(self.handle);
self.handle_scope.deinit();
}
};
pub fn queueMutationDelivery(self: *Context) !void {
self.isolate.enqueueMicrotask(struct {
fn run(data: ?*anyopaque) callconv(.c) void {
const page: *Page = @ptrCast(@alignCast(data.?));
page.deliverMutations();
self.enqueueMicrotask(struct {
fn run(ctx: *Context) void {
ctx.page.deliverMutations();
}
}.run, self.page);
}.run);
}
pub fn queueIntersectionChecks(self: *Context) !void {
self.isolate.enqueueMicrotask(struct {
fn run(data: ?*anyopaque) callconv(.c) void {
const page: *Page = @ptrCast(@alignCast(data.?));
page.performScheduledIntersectionChecks();
self.enqueueMicrotask(struct {
fn run(ctx: *Context) void {
ctx.page.performScheduledIntersectionChecks();
}
}.run, self.page);
}.run);
}
pub fn queueIntersectionDelivery(self: *Context) !void {
self.isolate.enqueueMicrotask(struct {
fn run(data: ?*anyopaque) callconv(.c) void {
const page: *Page = @ptrCast(@alignCast(data.?));
page.deliverIntersections();
self.enqueueMicrotask(struct {
fn run(ctx: *Context) void {
ctx.page.deliverIntersections();
}
}.run, self.page);
}.run);
}
pub fn queueSlotchangeDelivery(self: *Context) !void {
self.enqueueMicrotask(struct {
fn run(ctx: *Context) void {
ctx.page.deliverSlotchangeEvents();
}
}.run);
}
// Helper for executing a Microtask on this Context. In V8, microtasks aren't
// associated to a Context - they are just functions to execute in an Isolate.
// But for these Context microtasks, we want to (a) make sure the context isn't
// being shut down and (b) that it's entered.
fn enqueueMicrotask(self: *Context, callback: anytype) void {
self.isolate.enqueueMicrotask(struct {
fn run(data: ?*anyopaque) callconv(.c) void {
const page: *Page = @ptrCast(@alignCast(data.?));
page.deliverSlotchangeEvents();
const ctx: *Context = @ptrCast(@alignCast(data.?));
if (ctx.shutting_down) {
return;
}
var hs: js.HandleScope = undefined;
const entered = ctx.enter(&hs);
defer entered.exit();
callback(ctx);
}
}.run, self.page);
}.run, self);
}
pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void {
self.isolate.enqueueMicrotaskFunc(cb);
}
pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque) void) !*FinalizerCallback {
const fc = try self.finalizer_callback_pool.create();
fc.* = .{
.ctx = self,
.ptr = ptr,
.global = global,
.finalizerFn = finalizerFn,
};
return fc;
}
// == Misc ==
// A type that has a finalizer can have its finalizer called one of two ways.
// The first is from V8 via the WeakCallback we give to weakRef. But that isn't
// guaranteed to fire, so we track this in ctx._finalizers and call them on
// context shutdown.
const FinalizerCallback = struct {
pub const FinalizerCallback = struct {
ctx: *Context,
ptr: *anyopaque,
global: v8.Global,
finalizerFn: *const fn (ptr: *anyopaque) void,
pub fn init(ptr: anytype) FinalizerCallback {
const T = bridge.Struct(@TypeOf(ptr));
return .{
.ptr = ptr,
.finalizerFn = struct {
pub fn wrap(self: *anyopaque) void {
T.JsApi.Meta.finalizer.from_zig(self);
}
}.wrap,
};
}
pub fn deinit(self: FinalizerCallback) void {
pub fn deinit(self: *FinalizerCallback) void {
self.finalizerFn(self.ptr);
self.ctx.finalizer_callback_pool.destroy(self);
}
};
@@ -940,7 +1074,7 @@ pub fn stopCpuProfiler(self: *Context) ![]const u8 {
const title = self.isolate.initStringHandle("v8_cpu_profile");
const handle = v8.v8__CpuProfiler__StopProfiling(self.cpu_profiler.?, title) orelse return error.NoProfiles;
const string_handle = v8.v8__CpuProfile__Serialize(handle, self.isolate.handle) orelse return error.NoProfile;
return ls.local.jsStringToZig(string_handle, .{});
return (js.String{ .local = &ls.local, .handle = string_handle }).toSlice();
}
pub fn startHeapProfiler(self: *Context) void {
@@ -972,7 +1106,7 @@ pub fn stopHeapProfiler(self: *Context) !struct { []const u8, []const u8 } {
const string_handle = v8.v8__AllocationProfile__Serialize(profile, self.isolate.handle);
v8.v8__HeapProfiler__StopSamplingHeapProfiler(self.heap_profiler.?);
v8.v8__AllocationProfile__Delete(profile);
break :blk try ls.local.jsStringToZig(string_handle, .{});
break :blk try (js.String{ .local = &ls.local, .handle = string_handle.? }).toSlice();
};
const snapshot = blk: {
@@ -980,8 +1114,13 @@ pub fn stopHeapProfiler(self: *Context) !struct { []const u8, []const u8 } {
const string_handle = v8.v8__HeapSnapshot__Serialize(snapshot, self.isolate.handle);
v8.v8__HeapProfiler__StopTrackingHeapObjects(self.heap_profiler.?);
v8.v8__HeapSnapshot__Delete(snapshot);
break :blk try ls.local.jsStringToZig(string_handle, .{});
break :blk try (js.String{ .local = &ls.local, .handle = string_handle.? }).toSlice();
};
return .{ allocating, snapshot };
}
const UnknownPropertyStat = struct {
count: usize,
first_stack: []const u8,
};

View File

@@ -18,8 +18,11 @@
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");
@@ -28,13 +31,13 @@ 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,
@@ -44,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,
@@ -65,7 +70,17 @@ templates: []*const v8.FunctionTemplate,
// Global template created once per isolate and reused across all contexts
global_template: v8.Eternal,
pub fn init(allocator: Allocator, platform: *const Platform, snapshot: *Snapshot) !Env {
// 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);
@@ -78,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);
@@ -105,19 +121,19 @@ 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);
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)
@@ -126,7 +142,7 @@ pub fn init(allocator: Allocator, platform: *const Platform, snapshot: *Snapshot
const global_template_local = v8.v8__FunctionTemplate__InstanceTemplate(js_global).?;
v8.v8__ObjectTemplate__SetNamedHandler(global_template_local, &.{
.getter = bridge.unknownPropertyCallback,
.getter = bridge.unknownWindowPropertyCallback,
.setter = null,
.query = null,
.deleter = null,
@@ -136,41 +152,163 @@ pub fn init(allocator: Allocator, platform: *const Platform, snapshot: *Snapshot
.data = null,
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
});
v8.v8__Eternal__New(isolate.handle, @ptrCast(global_template_local), &global_eternal);
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);
@@ -182,13 +320,6 @@ pub fn pumpMessageLoop(self: *const Env) bool {
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
@@ -252,18 +383,13 @@ fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) v
.call_arena = ctx.call_arena,
};
const value =
if (v8.v8__PromiseRejectMessage__GetValue(&message_handle)) |v8_value|
// @HandleScope - no reason to create a js.Context here
local.valueHandleToString(v8_value, .{}) catch |err| @errorName(err)
else
"no value";
log.debug(.js, "unhandled rejection", .{
.value = value,
.stack = local.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,136 +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,
// 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();
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(&env.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 = env.context_id;
env.context_id = context_id + 1;
self.context = Context{
.page = page,
.id = context_id,
.entered = enter,
.isolate = isolate,
.handle = context_global,
.templates = env.templates,
.script_manager = &page._script_manager,
.call_arena = page.call_arena,
.arena = arena,
};
var context = &self.context.?;
try context.identity_map.putNoClobber(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(&self.context.?));
v8.v8__Context__SetEmbedderData(v8_context, 1, @ptrCast(data.handle));
return &self.context.?;
}
pub fn removeContext(self: *ExecutionWorld) void {
var context = &(self.context orelse return);
context.deinit();
self.context = null;
self.env.isolate.notifyContextDisposed();
_ = self.context_arena.reset(.{ .retain_with_limit = CONTEXT_ARENA_RETAIN });
}

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,8 +22,6 @@ const v8 = js.v8;
const log = @import("../../log.zig");
const Allocator = std.mem.Allocator;
const Function = @This();
local: *const js.Local,
@@ -71,7 +69,15 @@ pub fn newInstance(self: *const Function, caught: *js.TryCatch.Caught) !js.Objec
pub fn call(self: *const Function, comptime T: type, args: anytype) !T {
var caught: js.TryCatch.Caught = undefined;
return self._tryCallWithThis(T, self.getThis(), args, &caught) catch |err| {
return self._tryCallWithThis(T, self.getThis(), args, &caught, .{}) catch |err| {
log.warn(.js, "call caught", .{ .err = err, .caught = caught });
return 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;
};
@@ -79,21 +85,24 @@ pub fn call(self: *const Function, comptime T: type, args: anytype) !T {
pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype) !T {
var caught: js.TryCatch.Caught = undefined;
return self._tryCallWithThis(T, this, args, &caught) catch |err| {
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);
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);
return self._tryCallWithThis(T, this, args, caught, .{});
}
pub fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype, caught: *js.TryCatch.Caught) !T {
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;
@@ -147,6 +156,10 @@ pub fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype,
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;
};

View File

@@ -23,99 +23,76 @@ const v8 = js.v8;
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),
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 {
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
@@ -151,8 +128,8 @@ pub fn contextCreated(
}
}
pub fn contextDestroyed(self: *Inspector, local: *const js.Local) void {
v8.v8_inspector__Inspector__ContextDestroyed(self.handle, local.handle);
pub fn contextDestroyed(self: *Inspector, context: *const v8.Context) void {
v8.v8_inspector__Inspector__ContextDestroyed(self.handle, context);
}
pub fn resetContextGroup(self: *const Inspector) void {
@@ -163,51 +140,6 @@ pub fn resetContextGroup(self: *const Inspector) void {
v8.v8_inspector__Inspector__ResetContextGroup(self.handle, CONTEXT_GROUP_ID);
}
// 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,
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.session.wrapObject(
local.isolate.handle,
local.handle,
js_val.handle,
group,
generate_preview,
);
}
// 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 Inspector, allocator: Allocator, object_id: []const u8, local: *js.Local) !*anyopaque {
// just to indicate that the caller is responsible for ensure there's a local environment
_ = local;
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 TaggedOpaque.fromJS(*Node, @ptrCast(js_val)) catch return error.ObjectIdIsNotANode;
}
pub const RemoteObject = struct {
handle: *v8.RemoteObject,
@@ -254,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(
@@ -334,84 +355,6 @@ 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 getTaggedOpaque(value: *const v8.Value) ?*TaggedOpaque {
if (!v8.v8__Value__IsObject(value)) {
return null;
@@ -437,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(
@@ -493,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(
@@ -503,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

@@ -198,21 +198,28 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
// context.global_objects, we want to track it in context.identity_map.
v8.v8__Global__New(isolate.handle, js_obj.handle, gop.value_ptr);
if (@hasDecl(JsApi.Meta, "finalizer")) {
if (comptime IS_DEBUG) {
// You can normally return a "*Node" and we'll correctly
// handle it as what it really is, e.g. an HTMLScriptElement.
// But for finalizers, we can't do that. I think this
// limitation will be OK - this auto-resolution is largely
// limited to Node -> HtmlElement, none of which has finalizers
std.debug.assert(resolved.class_id == JsApi.Meta.class_id);
// It would be great if resolved knew the resolved type, but I
// can't figure out how to make that work, since it depends on
// the [runtime] `value`.
// We need the resolved finalizer, which we have in resolved.
// The above if statement would be more clear as:
// if (resolved.finalizer_from_v8) |finalizer| {
// But that's a runtime check.
// Instead, we check if the base has finalizer. The assumption
// here is that if a resolve type has a finalizer, then the base
// should have a finalizer too.
const fc = try ctx.createFinalizerCallback(gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?);
{
errdefer fc.deinit();
try ctx.finalizer_callbacks.put(ctx.arena, @intFromPtr(resolved.ptr), fc);
}
try ctx.finalizer_callbacks.put(ctx.arena, @intFromPtr(resolved.ptr), .init(value));
conditionallyFlagHandoff(value);
if (@hasDecl(JsApi.Meta, "weak")) {
if (comptime IS_DEBUG) {
std.debug.assert(JsApi.Meta.weak == true);
}
v8.v8__Global__SetWeakFinalizer(gop.value_ptr, resolved.ptr, JsApi.Meta.finalizer.from_v8, v8.kParameter);
v8.v8__Global__SetWeakFinalizer(gop.value_ptr, fc, resolved.finalizer_from_v8, v8.kParameter);
}
}
return js_obj;
@@ -316,6 +323,7 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts)
js.Value.Temp,
js.Object.Global,
js.Promise.Global,
js.Promise.Temp,
js.PromiseResolver.Global,
js.Module.Global => return .{ .local = self, .handle = @ptrCast(value.local(self).handle) },
else => {}
@@ -473,10 +481,10 @@ pub fn jsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !T {
if (ptr.child == u8) {
if (ptr.sentinel()) |s| {
if (comptime s == 0) {
return self.valueToStringZ(js_val, .{});
return try js_val.toStringSliceZ();
}
} else {
return self.valueToString(js_val, .{});
return try js_val.toStringSlice();
}
}
@@ -549,10 +557,8 @@ pub fn jsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !T {
},
.@"enum" => |e| {
if (@hasDecl(T, "js_enum_from_string")) {
if (!js_val.isString()) {
return error.InvalidArgument;
}
return std.meta.stringToEnum(T, try self.valueToString(js_val, .{})) orelse return error.InvalidArgument;
const js_str = js_val.isString() orelse return error.InvalidArgument;
return std.meta.stringToEnum(T, try js_str.toSlice()) orelse return error.InvalidArgument;
}
switch (@typeInfo(e.tag_type)) {
.int => return std.meta.intToEnum(T, try jsIntToZig(e.tag_type, js_val)),
@@ -614,28 +620,27 @@ fn jsValueToStruct(self: *const Local, comptime T: type, js_val: js.Value) !?T {
return try obj.persist();
},
js.Promise.Global => {
js.Promise.Global, js.Promise.Temp => {
if (!js_val.isPromise()) {
return null;
}
const promise = js.Promise{
.ctx = self,
const js_promise = js.Promise{
.local = self,
.handle = @ptrCast(js_val.handle),
};
return try promise.persist();
return switch (T) {
js.Promise.Temp => try js_promise.temp(),
js.Promise.Global => try js_promise.persist(),
else => unreachable,
};
},
string.String => {
if (!js_val.isString()) {
return null;
}
return try self.valueToStringSSO(js_val, .{ .allocator = self.ctx.call_arena });
const js_str = js_val.isString() orelse return null;
return try js_str.toSSO(false);
},
string.Global => {
if (!js_val.isString()) {
return null;
}
// Use arena for persistent strings
return .{ .str = try self.valueToStringSSO(js_val, .{ .allocator = self.ctx.arena }) };
const js_str = js_val.isString() orelse return null;
return try js_str.toSSO(true);
},
else => {
if (!js_val.isObject()) {
@@ -883,7 +888,7 @@ fn probeJsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !Pr
}
if (ptr.child == u8) {
if (js_val.isString()) {
if (v8.v8__Value__IsString(js_val.handle)) {
return .{ .ok = {} };
}
// anything can be coerced into a string
@@ -931,10 +936,11 @@ fn probeJsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !Pr
if (js_arr.len() == arr.len) {
return .{ .ok = {} };
}
} else if (js_val.isString() and arr.child == u8) {
const str = try js_val.toString(self.local);
if (str.lenUtf8(self.isolate) == arr.len) {
return .{ .ok = {} };
} else if (arr.child == u8) {
if (js_val.isString()) |js_str| {
if (js_str.lenUtf8(self.isolate) == arr.len) {
return .{ .ok = {} };
}
}
}
return .{ .invalid = {} };
@@ -947,7 +953,7 @@ fn probeJsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !Pr
.@"struct" => {
// Handle string.String and string.Global specially
if (T == string.String or T == string.Global) {
if (js_val.isString()) {
if (v8.v8__Value__IsString(js_val.handle)) {
return .{ .ok = {} };
}
// Anything can be coerced to a string
@@ -1032,9 +1038,12 @@ fn jsUnsignedIntToZig(comptime T: type, max: comptime_int, maybe: u32) !T {
// This function recursively walks the _type union field (if there is one) to
// get the most specific class_id possible.
const Resolved = struct {
weak: bool,
ptr: *anyopaque,
class_id: u16,
prototype_chain: []const @import("TaggedOpaque.zig").PrototypeChainEntry,
finalizer_from_v8: ?*const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void = null,
finalizer_from_zig: ?*const fn (ptr: *anyopaque) void = null,
};
pub fn resolveValue(value: anytype) Resolved {
const T = bridge.Struct(@TypeOf(value));
@@ -1062,13 +1071,28 @@ pub fn resolveValue(value: anytype) Resolved {
}
fn resolveT(comptime T: type, value: *anyopaque) Resolved {
const Meta = T.JsApi.Meta;
return .{
.ptr = value,
.class_id = T.JsApi.Meta.class_id,
.prototype_chain = &T.JsApi.Meta.prototype_chain,
.class_id = Meta.class_id,
.prototype_chain = &Meta.prototype_chain,
.weak = if (@hasDecl(Meta, "weak")) Meta.weak else false,
.finalizer_from_v8 = if (@hasDecl(Meta, "finalizer")) Meta.finalizer.from_v8 else null,
.finalizer_from_zig = if (@hasDecl(Meta, "finalizer")) Meta.finalizer.from_zig else null,
};
}
fn conditionallyFlagHandoff(value: anytype) void {
const T = bridge.Struct(@TypeOf(value));
if (@hasField(T, "_v8_handoff")) {
value._v8_handoff = true;
return;
}
if (@hasField(T, "_proto")) {
conditionallyFlagHandoff(value._proto);
}
}
pub fn stackTrace(self: *const Local) !?[]const u8 {
const isolate = self.isolate;
const separator = log.separator();
@@ -1080,14 +1104,15 @@ pub fn stackTrace(self: *const Local) !?[]const u8 {
const frame_count = v8.v8__StackTrace__GetFrameCount(stack_trace_handle);
if (v8.v8__StackTrace__CurrentScriptNameOrSourceURL__STATIC(isolate.handle)) |script| {
try writer.print("{s}<{s}>", .{ separator, try self.jsStringToZig(script, .{}) });
const stack = js.String{ .local = self, .handle = script };
try writer.print("{s}<{f}>", .{ separator, stack });
}
for (0..@intCast(frame_count)) |i| {
const frame_handle = v8.v8__StackTrace__GetFrame(stack_trace_handle, isolate.handle, @intCast(i)).?;
if (v8.v8__StackFrame__GetFunctionName(frame_handle)) |name| {
const script = try self.jsStringToZig(name, .{});
try writer.print("{s}{s}:{d}", .{ separator, script, v8.v8__StackFrame__GetLineNumber(frame_handle) });
const script = js.String{ .local = self, .handle = name };
try writer.print("{s}{f}:{d}", .{ separator, script, v8.v8__StackFrame__GetLineNumber(frame_handle) });
} else {
try writer.print("{s}<anonymous>:{d}", .{ separator, v8.v8__StackFrame__GetLineNumber(frame_handle) });
}
@@ -1095,100 +1120,6 @@ pub fn stackTrace(self: *const Local) !?[]const u8 {
return buf.items;
}
// == Stringifiers ==
const ToStringOpts = struct {
allocator: ?Allocator = null,
};
pub fn valueToString(self: *const Local, js_val: js.Value, opts: ToStringOpts) ![]u8 {
return self.valueHandleToString(js_val.handle, opts);
}
pub fn valueToStringZ(self: *const Local, js_val: js.Value, opts: ToStringOpts) ![:0]u8 {
return self.valueHandleToStringZ(js_val.handle, opts);
}
pub fn valueHandleToString(self: *const Local, js_val: *const v8.Value, opts: ToStringOpts) ![]u8 {
return self._valueToString(false, js_val, opts);
}
pub fn valueHandleToStringZ(self: *const Local, js_val: *const v8.Value, opts: ToStringOpts) ![:0]u8 {
return self._valueToString(true, js_val, opts);
}
fn _valueToString(self: *const Local, comptime null_terminate: bool, value_handle: *const v8.Value, opts: ToStringOpts) !(if (null_terminate) [:0]u8 else []u8) {
var resolved_value_handle = value_handle;
if (v8.v8__Value__IsSymbol(value_handle)) {
const symbol_handle = v8.v8__Symbol__Description(@ptrCast(value_handle), self.isolate.handle).?;
resolved_value_handle = @ptrCast(symbol_handle);
}
const string_handle = v8.v8__Value__ToString(resolved_value_handle, self.handle) orelse {
return error.JsException;
};
return self._jsStringToZig(null_terminate, string_handle, opts);
}
pub fn jsStringToZig(self: *const Local, str: anytype, opts: ToStringOpts) ![]u8 {
return self._jsStringToZig(false, str, opts);
}
pub fn jsStringToZigZ(self: *const Local, str: anytype, opts: ToStringOpts) ![:0]u8 {
return self._jsStringToZig(true, str, opts);
}
fn _jsStringToZig(self: *const Local, comptime null_terminate: bool, str: anytype, opts: ToStringOpts) !(if (null_terminate) [:0]u8 else []u8) {
const handle = if (@TypeOf(str) == js.String) str.handle else str;
const len = v8.v8__String__Utf8Length(handle, self.isolate.handle);
const allocator = opts.allocator orelse self.call_arena;
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, self.isolate.handle, buf.ptr, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
std.debug.assert(n == len);
return buf;
}
// Convert JS string to string.String with SSO
pub fn valueToStringSSO(self: *const Local, js_val: js.Value, opts: ToStringOpts) !string.String {
const string_handle = v8.v8__Value__ToString(js_val.handle, self.handle) orelse {
return error.JsException;
};
return self.jsStringToStringSSO(string_handle, opts);
}
pub fn jsStringToStringSSO(self: *const Local, str: anytype, opts: ToStringOpts) !string.String {
const handle = if (@TypeOf(str) == js.String) str.handle else str;
const len: usize = @intCast(v8.v8__String__Utf8Length(handle, self.isolate.handle));
if (len <= 12) {
var content: [12]u8 = undefined;
const n = v8.v8__String__WriteUtf8(handle, self.isolate.handle, &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 allocator = opts.allocator orelse self.call_arena;
const buf = try allocator.alloc(u8, len);
const n = v8.v8__String__WriteUtf8(handle, self.isolate.handle, 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,
} },
};
}
// == Promise Helpers ==
pub fn rejectPromise(self: *const Local, value: anytype) !js.Promise {
var resolver = js.PromiseResolver.init(self);
@@ -1233,18 +1164,16 @@ fn _debugValue(self: *const Local, js_val: js.Value, seen: *std.AutoHashMapUnman
if (js_val.isSymbol()) {
const symbol_handle = v8.v8__Symbol__Description(@ptrCast(js_val.handle), self.isolate.handle).?;
const js_sym_str = try self.valueToString(.{ .local = self, .handle = symbol_handle }, .{});
return writer.print("{s} (symbol)", .{js_sym_str});
return writer.print("{f} (symbol)", .{js.String{ .local = self, .handle = @ptrCast(symbol_handle) }});
}
const js_type = try self.jsStringToZig(js_val.typeOf(), .{});
const js_val_str = try self.valueToString(js_val, .{});
const js_val_str = try js_val.toStringSlice();
if (js_val_str.len > 2000) {
try writer.writeAll(js_val_str[0..2000]);
try writer.writeAll(" ... (truncated)");
} else {
try writer.writeAll(js_val_str);
}
return writer.print(" ({s})", .{js_type});
return writer.print(" ({f})", .{js_val.typeOf()});
}
const js_obj = js_val.toObject();
@@ -1266,7 +1195,7 @@ fn _debugValue(self: *const Local, js_val: js.Value, seen: *std.AutoHashMapUnman
}
const own_len = js_obj.getOwnPropertyNames().len();
if (own_len == 0) {
const js_val_str = try self.valueToString(js_val, .{});
const js_val_str = try js_val.toStringSlice();
if (js_val_str.len > 2000) {
try writer.writeAll(js_val_str[0..2000]);
return writer.writeAll(" ... (truncated)");
@@ -1281,10 +1210,11 @@ fn _debugValue(self: *const Local, js_val: js.Value, seen: *std.AutoHashMapUnman
try writer.writeByte('\n');
}
const field_name = try names_arr.get(@intCast(i));
const name = try self.valueToString(field_name, .{});
const name = try field_name.toStringSlice();
try writer.splatByteAll(' ', depth);
try writer.writeAll(name);
try writer.writeAll(": ");
const field_val = try js_obj.get(name);
try self._debugValue(field_val, seen, depth + 1, writer);
if (i != len - 1) {

View File

@@ -131,7 +131,7 @@ const Requests = struct {
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,10 +22,6 @@ const v8 = js.v8;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Context = @import("Context.zig");
const Allocator = std.mem.Allocator;
const Object = @This();
local: *const js.Local,
@@ -80,10 +76,6 @@ pub fn defineOwnProperty(self: Object, name: []const u8, value: js.Value, attr:
}
}
pub fn toString(self: Object) ![]const u8 {
return self.local.ctx.valueToString(self.toValue(), .{});
}
pub fn toValue(self: Object) js.Value {
return .{
.local = self.local,
@@ -201,8 +193,8 @@ pub const NameIterator = struct {
}
self.idx += 1;
const js_val_handle = v8.v8__Object__GetIndex(@ptrCast(self.handle), self.local.handle, idx) orelse return error.JsException;
const js_val = js.Value{ .local = self.local, .handle = js_val_handle };
return try self.local.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

@@ -47,25 +47,49 @@ pub fn thenAndCatch(self: Promise, on_fulfilled: js.Function, on_rejected: js.Fu
}
return error.PromiseChainFailed;
}
pub fn persist(self: Promise) !Global {
return self._persist(true);
}
pub fn temp(self: Promise) !Temp {
return self._persist(false);
}
fn _persist(self: *const Promise, 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_promises.append(ctx.arena, 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 const Global = struct {
handle: v8.Global,
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, l: *const js.Local) Promise {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
};
// 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

@@ -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,9 +19,8 @@
const std = @import("std");
const builtin = @import("builtin");
const js = @import("js/js.zig");
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;
@@ -48,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) {
@@ -64,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,
});
}
@@ -73,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;
@@ -106,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

@@ -261,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)
@@ -289,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) {
@@ -301,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
}
@@ -311,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));
@@ -336,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;
@@ -357,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;
}
@@ -393,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;
@@ -402,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);
@@ -413,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);
@@ -453,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
@@ -463,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 => {},
@@ -490,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,8 +18,10 @@
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;
@@ -28,26 +30,82 @@ const String = @This();
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.local.isolate.handle;
const allocator = opts.allocator orelse self.local.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

@@ -20,6 +20,8 @@ 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();
@@ -32,8 +34,19 @@ pub fn init(self: *TryCatch, l: *const js.Local) void {
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 (!v8.v8__TryCatch__HasCaught(&self.handle)) {
if (self.hasCaught() == false) {
return null;
}
@@ -46,12 +59,38 @@ pub fn caught(self: TryCatch, allocator: Allocator) ?Caught {
const exception: ?[]const u8 = blk: {
const handle = v8.v8__TryCatch__Exception(&self.handle) orelse break :blk null;
break :blk l.valueHandleToString(@ptrCast(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, l.handle) orelse break :blk null;
break :blk l.valueHandleToString(@ptrCast(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 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 .{

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;
@@ -34,8 +35,12 @@ 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 {
@@ -204,35 +209,40 @@ pub fn toPromise(self: Value) js.Promise {
};
}
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.local.handle, self.handle, null) orelse return error.JsException;
return self.local.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 l = self.local;
if (self.isSymbol()) {
const sym_handle = v8.v8__Symbol__Description(@ptrCast(self.handle), l.isolate.handle).?;
return _toString(.{ .handle = @ptrCast(sym_handle), .local = l }, null_terminate, opts);
}
const str_handle = v8.v8__Value__ToString(self.handle, l.handle) orelse {
return error.JsException;
};
const str = js.String{ .local = l, .handle = str_handle };
if (comptime null_terminate) {
return js.String.toZigZ(str, opts);
}
return js.String.toZig(str, opts);
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 {
@@ -296,8 +306,8 @@ pub fn format(self: Value, writer: *std.Io.Writer) !void {
if (comptime IS_DEBUG) {
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 Temp = G(0);

View File

@@ -62,9 +62,21 @@ pub fn Builder(comptime T: type) type {
return Callable.init(T, func, opts);
}
pub fn property(value: anytype) Property {
pub fn property(value: anytype, opts: Property.Opts) Property {
switch (@typeInfo(@TypeOf(value))) {
.comptime_int, .int => return .{ .int = value },
.bool => return Property.init(.{ .bool = value }, opts),
.null => return Property.init(.null, opts),
.comptime_int, .int => return Property.init(.{ .int = value }, opts),
.comptime_float, .float => return Property.init(.{ .float = value }, opts),
.pointer => |ptr| switch (ptr.size) {
.one => {
const one_info = @typeInfo(ptr.child);
if (one_info == .array and one_info.array.child == u8) {
return Property.init(.{ .string = value }, opts);
}
},
else => {},
},
else => {},
}
@compileError("Property for " ++ @typeName(@TypeOf(value)) ++ " hasn't been defined yet");
@@ -103,20 +115,18 @@ pub fn Builder(comptime T: type) type {
.from_v8 = struct {
fn wrap(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void {
const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?;
const self: *T = @ptrCast(@alignCast(ptr));
// This is simply a requirement of any type that Finalizes:
// It must have a _page: *Page field. We need it because
// we need to check the item has already been cleared
// (There are all types of weird timing issues that seem
// to be possible between finalization and context shutdown,
// we need to be defensive).
// There _ARE_ alternatives to this. But this is simple.
const ctx = self._page.js;
if (!ctx.identity_map.contains(@intFromPtr(ptr))) {
return;
const fc: *Context.FinalizerCallback = @ptrCast(@alignCast(ptr));
const ctx = fc.ctx;
const value_ptr = fc.ptr;
if (ctx.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
func(@ptrCast(@alignCast(value_ptr)), false);
ctx.release(value_ptr);
} else {
// A bit weird, but v8 _requires_ that we release it
// If we don't. We'll 100% crash.
v8.v8__Global__Reset(&fc.global);
}
func(self, false);
ctx.release(ptr);
}
}.wrap,
};
@@ -149,6 +159,7 @@ pub const Constructor = struct {
pub const Function = struct {
static: bool,
arity: usize,
func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
const Opts = struct {
@@ -161,6 +172,7 @@ pub const Function = struct {
fn init(comptime T: type, comptime func: anytype, comptime opts: Opts) Function {
return .{
.static = opts.static,
.arity = getArity(@TypeOf(func)),
.func = struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
@@ -185,6 +197,22 @@ pub const Function = struct {
}.wrap,
};
}
fn getArity(comptime T: type) usize {
var count: usize = 0;
var params = @typeInfo(T).@"fn".params;
for (params[1..]) |p| { // start at 1, skip self
const PT = p.type.?;
if (PT == *Page or PT == *const Page) {
break;
}
if (@typeInfo(PT) == .optional) {
break;
}
count += 1;
}
return count;
}
};
pub const Accessor = struct {
@@ -194,9 +222,9 @@ pub const Accessor = struct {
const Opts = struct {
static: bool = false,
cache: ?[]const u8 = null,
as_typed_array: bool = false,
null_as_undefined: bool = false,
dom_exception: bool = false,
};
fn init(comptime T: type, comptime getter: anytype, comptime setter: anytype, comptime opts: Opts) Accessor {
@@ -214,13 +242,13 @@ pub const Accessor = struct {
if (comptime opts.static) {
caller.function(T, getter, handle.?, .{
.cache = opts.cache,
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
} else {
caller.method(T, getter, handle.?, .{
.cache = opts.cache,
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
@@ -238,6 +266,7 @@ pub const Accessor = struct {
defer caller.deinit();
caller.method(T, setter, handle.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
@@ -395,8 +424,35 @@ pub const Callable = struct {
}
};
pub const Property = union(enum) {
int: i64,
pub const Property = struct {
value: Value,
template: bool,
const Value = union(enum) {
null,
int: i64,
float: f64,
bool: bool,
string: []const u8,
};
const Opts = struct {
template: bool,
};
fn init(value: Value, opts: Opts) Property {
return .{
.value = value,
.template = opts.template,
};
}
pub fn getter(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const value = v8.v8__FunctionCallbackInfo__Data(handle.?);
var rv: v8.ReturnValue = undefined;
v8.v8__FunctionCallbackInfo__GetReturnValue(handle.?, &rv);
v8.v8__ReturnValue__Set(rv, value);
}
};
const Finalizer = struct {
@@ -410,7 +466,7 @@ const Finalizer = struct {
from_v8: *const fn (?*const v8.WeakCallbackInfo) callconv(.c) void,
};
pub fn unknownPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
pub fn unknownWindowPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
@@ -422,7 +478,7 @@ pub fn unknownPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.Prope
hs.init(local.isolate);
defer hs.deinit();
const property: []const u8 = local.valueHandleToString(@ptrCast(c_name.?), .{}) catch {
const property: []const u8 = js.String.toSlice(.{ .local = local, .handle = @ptrCast(c_name.?) }) catch {
return 0;
};
@@ -437,11 +493,21 @@ pub fn unknownPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.Prope
}
if (comptime IS_DEBUG) {
if (std.mem.startsWith(u8, property, "__")) {
// some frameworks will extend built-in types using a __ prefix
// these should always be safe to ignore.
return 0;
}
const ignored = std.StaticStringMap(void).initComptime(.{
.{ "Deno", {} },
.{ "process", {} },
.{ "ShadyDOM", {} },
.{ "ShadyCSS", {} },
// a lot of sites seem to like having their own window.config.
.{ "config", {} },
.{ "litNonce", {} },
.{ "litHtmlVersions", {} },
.{ "litElementVersions", {} },
@@ -457,13 +523,12 @@ pub fn unknownPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.Prope
.{ "__google_recaptcha_client", {} },
.{ "CLOSURE_FLAGS", {} },
.{ "__REACT_DEVTOOLS_GLOBAL_HOOK__", {} },
.{ "ApplePaySession", {} },
});
if (!ignored.has(property)) {
log.debug(.unknown_prop, "unknown global property", .{
.info = "but the property can exist in pure JS",
.stack = local.stackTrace() catch "???",
.property = property,
});
const key = std.fmt.bufPrint(&local.ctx.page.buf, "Window:{s}", .{property}) catch return 0;
logUnknownProperty(local, key) catch return 0;
}
}
@@ -471,6 +536,83 @@ pub fn unknownPropertyCallback(c_name: ?*const v8.Name, handle: ?*const v8.Prope
return 0;
}
// Only used for debugging
pub fn unknownObjectPropertyCallback(comptime JsApi: type) *const fn (?*const v8.Name, ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
if (comptime !IS_DEBUG) {
@compileError("unknownObjectPropertyCallback should only be used in debug builds");
}
return struct {
fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
const local = &caller.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const property: []const u8 = js.String.toSlice(.{ .local = local, .handle = @ptrCast(c_name.?) }) catch {
return 0;
};
if (std.mem.startsWith(u8, property, "__")) {
// some frameworks will extend built-in types using a __ prefix
// these should always be safe to ignore.
return 0;
}
if (std.mem.startsWith(u8, property, "jQuery")) {
return 0;
}
if (JsApi == @import("../webapi/cdata/Text.zig").JsApi or JsApi == @import("../webapi/cdata/Comment.zig").JsApi) {
if (std.mem.eql(u8, property, "tagName")) {
// knockout does this, a lot.
return 0;
}
}
if (JsApi == @import("../webapi/element/Html.zig").JsApi or JsApi == @import("../webapi/Element.zig").JsApi or JsApi == @import("../webapi/element/html/Custom.zig").JsApi) {
// react ?
if (std.mem.eql(u8, property, "props")) return 0;
if (std.mem.eql(u8, property, "hydrated")) return 0;
if (std.mem.eql(u8, property, "isHydrated")) return 0;
}
if (JsApi == @import("../webapi/Console.zig").JsApi) {
if (std.mem.eql(u8, property, "firebug")) return 0;
}
const ignored = std.StaticStringMap(void).initComptime(.{});
if (!ignored.has(property)) {
const key = std.fmt.bufPrint(&local.ctx.page.buf, "{s}:{s}", .{ if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi), property }) catch return 0;
logUnknownProperty(local, key) catch return 0;
}
// not intercepted
return 0;
}
}.wrap;
}
fn logUnknownProperty(local: *const js.Local, key: []const u8) !void {
const ctx = local.ctx;
const gop = try ctx.unknown_properties.getOrPut(ctx.arena, key);
if (gop.found_existing) {
gop.value_ptr.count += 1;
} else {
gop.key_ptr.* = try ctx.arena.dupe(u8, key);
gop.value_ptr.* = .{
.count = 1,
.first_stack = try ctx.arena.dupe(u8, (try local.stackTrace()) orelse "???"),
};
}
}
// Given a Type, returns the length of the prototype chain, including self
fn prototypeChainLength(comptime T: type) usize {
var l: usize = 1;
@@ -696,6 +838,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/element/html/Option.zig"),
@import("../webapi/element/html/Output.zig"),
@import("../webapi/element/html/Paragraph.zig"),
@import("../webapi/element/html/Picture.zig"),
@import("../webapi/element/html/Param.zig"),
@import("../webapi/element/html/Pre.zig"),
@import("../webapi/element/html/Progress.zig"),
@@ -737,6 +880,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/event/MouseEvent.zig"),
@import("../webapi/event/PointerEvent.zig"),
@import("../webapi/event/KeyboardEvent.zig"),
@import("../webapi/event/PromiseRejectionEvent.zig"),
@import("../webapi/MessageChannel.zig"),
@import("../webapi/MessagePort.zig"),
@import("../webapi/media/MediaError.zig"),
@@ -761,6 +905,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/URL.zig"),
@import("../webapi/Window.zig"),
@import("../webapi/Performance.zig"),
@import("../webapi/PluginArray.zig"),
@import("../webapi/MutationObserver.zig"),
@import("../webapi/IntersectionObserver.zig"),
@import("../webapi/CustomElementRegistry.zig"),
@@ -769,6 +914,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/Blob.zig"),
@import("../webapi/File.zig"),
@import("../webapi/Screen.zig"),
@import("../webapi/VisualViewport.zig"),
@import("../webapi/PerformanceObserver.zig"),
@import("../webapi/navigation/Navigation.zig"),
@import("../webapi/navigation/NavigationEventTarget.zig"),

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,12 +19,10 @@
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");
@@ -34,7 +32,6 @@ 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");
@@ -47,6 +44,7 @@ pub const BigInt = @import("BigInt.zig");
pub const Number = @import("Number.zig");
pub const Integer = @import("Integer.zig");
pub const PromiseResolver = @import("PromiseResolver.zig");
pub const PromiseRejection = @import("PromiseRejection.zig");
const Allocator = std.mem.Allocator;

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

@@ -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

@@ -488,3 +488,27 @@
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,6 +183,44 @@
}
</script>
<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)

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

@@ -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,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

@@ -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

@@ -541,3 +541,55 @@
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

@@ -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

@@ -76,7 +76,9 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, local: *const js.Local, page:
}
// Dispatch abort event
const event = try Event.initTrusted("abort", .{}, page);
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,
@@ -99,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",
});
@@ -116,7 +118,7 @@ pub fn throwIfAborted(self: *const AbortSignal, page: *Page) !ThrowIfAborted {
if (self._aborted) {
const exception = switch (self._reason) {
.string => |str| local.throw(str),
.js_val => |js_val| local.throw(try local.toLocal(js_val).toString(.{ .allocator = page.call_arena })),
.js_val => |js_val| local.throw(try local.toLocal(js_val).toStringSlice()),
.undefined => local.throw("AbortError"),
};
return .{ .exception = exception };

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

@@ -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);
@@ -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>
@@ -20,7 +20,6 @@ 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();

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;
}
}
}

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);
@@ -64,6 +65,13 @@ pub fn getFilter(self: *const DOMNodeIterator) ?FilterOpts {
}
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;
@@ -95,6 +103,13 @@ pub fn nextNode(self: *DOMNodeIterator, page: *Page) !?*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;
@@ -119,6 +134,10 @@ pub fn previousNode(self: *DOMNodeIterator, page: *Page) !?*Node {
}
}
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)) {
@@ -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();

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,8 +30,6 @@ 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");
@@ -58,6 +56,20 @@ _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,
html: *HTMLDocument,
@@ -105,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);
}
@@ -127,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
@@ -153,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) {
@@ -249,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));
}
@@ -794,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
@@ -929,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, .{});
@@ -938,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 });
@@ -989,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

@@ -49,129 +49,6 @@ pub const RelListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTok
pub const ShadowRootLookup = std.AutoHashMapUnmanaged(*Element, *ShadowRoot);
pub const AssignedSlotLookup = std.AutoHashMapUnmanaged(*Element, *Html.Slot);
/// Better to discriminate it since not directly a pointer int.
///
/// See `calcAttrListenerKey` to obtain one.
const AttrListenerKey = u64;
/// Use `getAttrListenerKey` to create a key.
pub const AttrListenerLookup = std.AutoHashMapUnmanaged(AttrListenerKey, js.Function.Global);
/// Enum of known event listeners; increasing the size of it (u7)
/// can cause `AttrListenerKey` to behave incorrectly.
pub const KnownListener = enum(u7) {
abort,
animationcancel,
animationend,
animationiteration,
animationstart,
auxclick,
beforeinput,
beforematch,
beforetoggle,
blur,
cancel,
canplay,
canplaythrough,
change,
click,
close,
command,
contentvisibilityautostatechange,
contextlost,
contextmenu,
contextrestored,
copy,
cuechange,
cut,
dblclick,
drag,
dragend,
dragenter,
dragexit,
dragleave,
dragover,
dragstart,
drop,
durationchange,
emptied,
ended,
@"error",
focus,
formdata,
fullscreenchange,
fullscreenerror,
gotpointercapture,
input,
invalid,
keydown,
keypress,
keyup,
load,
loadeddata,
loadedmetadata,
loadstart,
lostpointercapture,
mousedown,
mousemove,
mouseout,
mouseover,
mouseup,
paste,
pause,
play,
playing,
pointercancel,
pointerdown,
pointerenter,
pointerleave,
pointermove,
pointerout,
pointerover,
pointerrawupdate,
pointerup,
progress,
ratechange,
reset,
resize,
scroll,
scrollend,
securitypolicyviolation,
seeked,
seeking,
select,
selectionchange,
selectstart,
slotchange,
stalled,
submit,
@"suspend",
timeupdate,
toggle,
transitioncancel,
transitionend,
transitionrun,
transitionstart,
volumechange,
waiting,
wheel,
};
/// Calculates a lookup key (`AttrListenerKey`) to use with `AttrListenerLookup` for an element.
/// NEVER use generated key to retrieve a pointer back. Portability is not guaranteed.
pub fn calcAttrListenerKey(self: *Element, event_type: KnownListener) AttrListenerKey {
// We can use `Element` for the key too; `EventTarget` is strict about
// its size and alignment, though.
const target = self.asEventTarget();
// Check if we have 3 bits available from alignment of 8.
lp.assert(@alignOf(@TypeOf(target)) == 8, "createLookupKey: incorrect alignment", .{
.event_target_alignment = @alignOf(@TypeOf(target)),
});
const ptr = @intFromPtr(target) >> 3;
lp.assert(ptr < (1 << 57), "createLookupKey: pointer overflow", .{ .ptr = ptr });
return ptr | (@as(u64, @intFromEnum(event_type)) << 57);
}
pub const Namespace = enum(u8) {
html,
svg,
@@ -358,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",
@@ -434,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",
@@ -643,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| {
@@ -656,7 +535,7 @@ pub fn setAttributeNS(
qualified_name[idx + 1 ..]
else
qualified_name;
return self.setAttribute(.wrap(local_name), .wrap(value), page);
return self.setAttribute(.wrap(local_name), value, page);
}
pub fn setAttributeSafe(self: *Element, name: String, value: String, page: *Page) !void {
@@ -837,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();
@@ -852,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);
}
@@ -860,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);
}
@@ -870,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);
}
@@ -1220,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));
}
@@ -1255,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());
@@ -1298,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,
@@ -1331,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,
@@ -1343,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,
@@ -1390,6 +1319,7 @@ pub const Tag = enum {
dfn,
dialog,
div,
directory,
dl,
dt,
embed,
@@ -1398,6 +1328,7 @@ pub const Tag = enum {
fieldset,
figure,
form,
font,
footer,
g,
h1,
@@ -1417,10 +1348,13 @@ pub const Tag = enum {
img,
input,
ins,
label,
legend,
li,
line,
link,
main,
map,
marquee,
media,
menu,
@@ -1436,8 +1370,10 @@ pub const Tag = enum {
p,
path,
param,
picture,
polygon,
polyline,
pre,
progress,
quote,
rect,
@@ -1528,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, .{});
@@ -1542,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 });
@@ -1563,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, .{});
@@ -1589,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

@@ -79,11 +79,12 @@ 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);
try page._event_manager.dispatchWithFunction(
page.window.asEventTarget(),
event.asEvent(),
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 {
@@ -258,14 +295,20 @@ pub fn deliverEntries(self: *IntersectionObserver, page: *Page) !void {
}
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;
}
@@ -301,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, .{});
@@ -320,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 {
@@ -122,14 +122,15 @@ const PostMessageCallback = struct {
return null;
}
const event = MessageEvent.initTrusted("message", .{
const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = self.message,
.origin = "",
.source = null,
}, page) catch |err| {
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
return null;
};
}).asEvent();
defer if (!event._v8_handoff) event.deinit(false);
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
@@ -137,7 +138,7 @@ const PostMessageCallback = struct {
page._event_manager.dispatchWithFunction(
self.port.asEventTarget(),
event.asEvent(),
event,
ls.toLocal(self.port._on_message),
.{ .context = "MessagePort message" },
) catch |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>
@@ -25,6 +25,10 @@ const Node = @import("Node.zig");
const Element = @import("Element.zig");
const log = @import("../../log.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
const Allocator = std.mem.Allocator;
pub fn registerTypes() []const type {
return &.{
MutationObserver,
@@ -34,9 +38,12 @@ pub fn registerTypes() []const type {
const MutationObserver = @This();
_callback: js.Function.Global,
_page: *Page,
_arena: Allocator,
_callback: js.Function.Temp,
_observing: std.ArrayList(Observing) = .{},
_pending_records: std.ArrayList(*MutationRecord) = .{},
/// Intrusively linked to next element (see Page.zig).
node: std.DoublyLinkedList.Node = .{},
@@ -55,19 +62,38 @@ pub const ObserveOptions = struct {
attributeFilter: ?[]const []const u8 = null,
};
pub fn init(callback: js.Function.Global, page: *Page) !*MutationObserver {
return page._factory.create(MutationObserver{
pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver {
const arena = try page.getArena(.{ .debug = "MutationObserver" });
errdefer page.releaseArena(arena);
const self = try arena.create(MutationObserver);
self.* = .{
._page = page,
._arena = arena,
._callback = callback,
});
};
return self;
}
pub fn deinit(self: *MutationObserver, 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: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void {
const arena = self._arena;
// Deep copy attributeFilter if present
var copied_options = options;
if (options.attributeFilter) |filter| {
const filter_copy = try page.arena.alloc([]const u8, filter.len);
const filter_copy = try arena.alloc([]const u8, filter.len);
for (filter, 0..) |name, i| {
filter_copy[i] = try page.arena.dupe(u8, name);
filter_copy[i] = try arena.dupe(u8, name);
}
copied_options.attributeFilter = filter_copy;
}
@@ -86,10 +112,11 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions,
// Register with page if this is our first observation
if (self._observing.items.len == 0) {
page.js.strongRef(self);
try page.registerMutationObserver(self);
}
try self._observing.append(page.arena, .{
try self._observing.append(arena, .{
.target = target,
.options = copied_options,
});
@@ -98,7 +125,11 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions,
pub fn disconnect(self: *MutationObserver, page: *Page) void {
page.unregisterMutationObserver(self);
self._observing.clearRetainingCapacity();
for (self._pending_records.items) |record| {
record.deinit(false);
}
self._pending_records.clearRetainingCapacity();
page.js.safeWeakRef(self);
}
pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord {
@@ -139,21 +170,25 @@ pub fn notifyAttributeChange(
}
}
const record = try page._factory.create(MutationRecord{
const arena = try self._page.getArena(.{ .debug = "MutationRecord" });
const record = try arena.create(MutationRecord);
record.* = .{
._page = page,
._arena = arena,
._type = .attributes,
._target = target_node,
._attribute_name = try page.arena.dupe(u8, attribute_name.str()),
._attribute_name = try arena.dupe(u8, attribute_name.str()),
._old_value = if (obs.options.attributeOldValue and old_value != null)
try page.arena.dupe(u8, old_value.?.str())
try arena.dupe(u8, old_value.?.str())
else
null,
._added_nodes = &.{},
._removed_nodes = &.{},
._previous_sibling = null,
._next_sibling = null,
});
};
try self._pending_records.append(page.arena, record);
try self._pending_records.append(self._arena, record);
try page.scheduleMutationDelivery();
break;
@@ -180,21 +215,25 @@ pub fn notifyCharacterDataChange(
continue;
}
const record = try page._factory.create(MutationRecord{
const arena = try self._page.getArena(.{ .debug = "MutationRecord" });
const record = try arena.create(MutationRecord);
record.* = .{
._page = page,
._arena = arena,
._type = .characterData,
._target = target,
._attribute_name = null,
._old_value = if (obs.options.characterDataOldValue and old_value != null)
try page.arena.dupe(u8, old_value.?)
try arena.dupe(u8, old_value.?)
else
null,
._added_nodes = &.{},
._removed_nodes = &.{},
._previous_sibling = null,
._next_sibling = null,
});
};
try self._pending_records.append(page.arena, record);
try self._pending_records.append(self._arena, record);
try page.scheduleMutationDelivery();
break;
@@ -224,18 +263,22 @@ pub fn notifyChildListChange(
continue;
}
const record = try page._factory.create(MutationRecord{
const arena = try self._page.getArena(.{ .debug = "MutationRecord" });
const record = try arena.create(MutationRecord);
record.* = .{
._page = page,
._arena = arena,
._type = .childList,
._target = target,
._attribute_name = null,
._old_value = null,
._added_nodes = try page.arena.dupe(*Node, added_nodes),
._removed_nodes = try page.arena.dupe(*Node, removed_nodes),
._added_nodes = try arena.dupe(*Node, added_nodes),
._removed_nodes = try arena.dupe(*Node, removed_nodes),
._previous_sibling = previous_sibling,
._next_sibling = next_sibling,
});
};
try self._pending_records.append(page.arena, record);
try self._pending_records.append(self._arena, record);
try page.scheduleMutationDelivery();
break;
@@ -263,7 +306,9 @@ pub fn deliverRecords(self: *MutationObserver, page: *Page) !void {
pub const MutationRecord = struct {
_type: Type,
_page: *Page,
_target: *Node,
_arena: Allocator,
_attribute_name: ?[]const u8,
_old_value: ?[]const u8,
_added_nodes: []const *Node,
@@ -277,6 +322,10 @@ pub const MutationRecord = struct {
characterData,
};
pub fn deinit(self: *const MutationRecord, _: bool) void {
self._page.releaseArena(self._arena);
}
pub fn getType(self: *const MutationRecord) []const u8 {
return switch (self._type) {
.attributes => "attributes",
@@ -327,6 +376,8 @@ pub const MutationRecord = struct {
pub const name = "MutationRecord";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(MutationRecord.deinit);
};
pub const @"type" = bridge.accessor(MutationRecord.getType, null, .{});
@@ -348,6 +399,8 @@ pub const JsApi = struct {
pub const name = "MutationObserver";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(MutationObserver.deinit);
};
pub const constructor = bridge.constructor(MutationObserver.init, .{});

View File

@@ -20,26 +20,20 @@ const std = @import("std");
const builtin = @import("builtin");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const PluginArray = @import("PluginArray.zig");
const Navigator = @This();
_pad: bool = false,
_plugins: PluginArray = .{},
pub const init: Navigator = .{};
pub fn getUserAgent(_: *const Navigator, page: *Page) []const u8 {
return page._session.browser.app.config.user_agent;
return page._session.browser.app.config.http_headers.user_agent;
}
pub fn getAppName(_: *const Navigator) []const u8 {
return "Netscape";
}
pub fn getAppCodeName(_: *const Navigator) []const u8 {
return "Netscape";
}
pub fn getAppVersion(_: *const Navigator) []const u8 {
return "1.0";
pub fn getLanguages(_: *const Navigator) [1][]const u8 {
return .{"en-US"};
}
pub fn getPlatform(_: *const Navigator) []const u8 {
@@ -52,48 +46,13 @@ pub fn getPlatform(_: *const Navigator) []const u8 {
};
}
pub fn getLanguage(_: *const Navigator) []const u8 {
return "en-US";
}
pub fn getLanguages(_: *const Navigator) [1][]const u8 {
return .{"en-US"};
}
pub fn getOnLine(_: *const Navigator) bool {
return true;
}
pub fn getCookieEnabled(_: *const Navigator) bool {
return true;
}
pub fn getHardwareConcurrency(_: *const Navigator) u32 {
return 4;
}
pub fn getMaxTouchPoints(_: *const Navigator) u32 {
return 0;
}
/// Returns the vendor name
pub fn getVendor(_: *const Navigator) []const u8 {
return "";
}
/// Returns the product name (typically "Gecko" for compatibility)
pub fn getProduct(_: *const Navigator) []const u8 {
return "Gecko";
}
/// Returns whether Java is enabled (always false)
pub fn javaEnabled(_: *const Navigator) bool {
return false;
}
/// Returns whether the browser is controlled by automation (always false)
pub fn getWebdriver(_: *const Navigator) bool {
return false;
pub fn getPlugins(self: *Navigator) *PluginArray {
return &self._plugins;
}
pub fn registerProtocolHandler(_: *const Navigator, scheme: []const u8, url: [:0]const u8, page: *const Page) !void {
@@ -176,19 +135,22 @@ pub const JsApi = struct {
// Read-only properties
pub const userAgent = bridge.accessor(Navigator.getUserAgent, null, .{});
pub const appName = bridge.accessor(Navigator.getAppName, null, .{});
pub const appCodeName = bridge.accessor(Navigator.getAppCodeName, null, .{});
pub const appVersion = bridge.accessor(Navigator.getAppVersion, null, .{});
pub const appName = bridge.property("Netscape", .{ .template = false });
pub const appCodeName = bridge.property("Netscape", .{ .template = false });
pub const appVersion = bridge.property("1.0", .{ .template = false });
pub const platform = bridge.accessor(Navigator.getPlatform, null, .{});
pub const language = bridge.accessor(Navigator.getLanguage, null, .{});
pub const language = bridge.property("en-US", .{ .template = false });
pub const languages = bridge.accessor(Navigator.getLanguages, null, .{});
pub const onLine = bridge.accessor(Navigator.getOnLine, null, .{});
pub const cookieEnabled = bridge.accessor(Navigator.getCookieEnabled, null, .{});
pub const hardwareConcurrency = bridge.accessor(Navigator.getHardwareConcurrency, null, .{});
pub const maxTouchPoints = bridge.accessor(Navigator.getMaxTouchPoints, null, .{});
pub const vendor = bridge.accessor(Navigator.getVendor, null, .{});
pub const product = bridge.accessor(Navigator.getProduct, null, .{});
pub const webdriver = bridge.accessor(Navigator.getWebdriver, null, .{});
pub const onLine = bridge.property(true, .{ .template = false });
pub const cookieEnabled = bridge.property(true, .{ .template = false });
pub const hardwareConcurrency = bridge.property(4, .{ .template = false });
pub const maxTouchPoints = bridge.property(0, .{ .template = false });
pub const vendor = bridge.property("", .{ .template = false });
pub const product = bridge.property("Gecko", .{ .template = false });
pub const webdriver = bridge.property(false, .{ .template = false });
pub const plugins = bridge.accessor(Navigator.getPlugins, null, .{});
pub const doNotTrack = bridge.property(null, .{ .template = false });
pub const globalPrivacyControl = bridge.property(true, .{ .template = false });
pub const registerProtocolHandler = bridge.function(Navigator.registerProtocolHandler, .{ .dom_exception = true });
pub const unregisterProtocolHandler = bridge.function(Navigator.unregisterProtocolHandler, .{ .dom_exception = true });

View File

@@ -457,6 +457,13 @@ pub fn ownerDocument(self: *const Node, page: *const Page) ?*Document {
return page.document;
}
pub fn isSameDocumentAs(self: *const Node, other: *const Node, page: *const Page) bool {
// Get the root document for each node
const self_doc = if (self._type == .document) self._type.document else self.ownerDocument(page);
const other_doc = if (other._type == .document) other._type.document else other.ownerDocument(page);
return self_doc == other_doc;
}
pub fn hasChildNodes(self: *const Node) bool {
return self.firstChild() != null;
}
@@ -667,7 +674,7 @@ pub fn setData(self: *Node, data: []const u8, page: *Page) !void {
}
pub fn normalize(self: *Node, page: *Page) !void {
var buffer: std.ArrayListUnmanaged(u8) = .empty;
var buffer: std.ArrayList(u8) = .empty;
return self._normalize(page.call_arena, &buffer, page);
}
@@ -797,7 +804,7 @@ fn isNodeBefore(node1: *const Node, node2: *const Node) bool {
return false;
}
fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayListUnmanaged(u8), page: *Page) !void {
fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayList(u8), page: *Page) !void {
var it = self.childrenIterator();
while (it.next()) |child| {
try child._normalize(allocator, buffer, page);
@@ -874,25 +881,25 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const ELEMENT_NODE = bridge.property(1);
pub const ATTRIBUTE_NODE = bridge.property(2);
pub const TEXT_NODE = bridge.property(3);
pub const CDATA_SECTION_NODE = bridge.property(4);
pub const ENTITY_REFERENCE_NODE = bridge.property(5);
pub const ENTITY_NODE = bridge.property(6);
pub const PROCESSING_INSTRUCTION_NODE = bridge.property(7);
pub const COMMENT_NODE = bridge.property(8);
pub const DOCUMENT_NODE = bridge.property(9);
pub const DOCUMENT_TYPE_NODE = bridge.property(10);
pub const DOCUMENT_FRAGMENT_NODE = bridge.property(11);
pub const NOTATION_NODE = bridge.property(12);
pub const ELEMENT_NODE = bridge.property(1, .{ .template = true });
pub const ATTRIBUTE_NODE = bridge.property(2, .{ .template = true });
pub const TEXT_NODE = bridge.property(3, .{ .template = true });
pub const CDATA_SECTION_NODE = bridge.property(4, .{ .template = true });
pub const ENTITY_REFERENCE_NODE = bridge.property(5, .{ .template = true });
pub const ENTITY_NODE = bridge.property(6, .{ .template = true });
pub const PROCESSING_INSTRUCTION_NODE = bridge.property(7, .{ .template = true });
pub const COMMENT_NODE = bridge.property(8, .{ .template = true });
pub const DOCUMENT_NODE = bridge.property(9, .{ .template = true });
pub const DOCUMENT_TYPE_NODE = bridge.property(10, .{ .template = true });
pub const DOCUMENT_FRAGMENT_NODE = bridge.property(11, .{ .template = true });
pub const NOTATION_NODE = bridge.property(12, .{ .template = true });
pub const DOCUMENT_POSITION_DISCONNECTED = bridge.property(0x01);
pub const DOCUMENT_POSITION_PRECEDING = bridge.property(0x02);
pub const DOCUMENT_POSITION_FOLLOWING = bridge.property(0x04);
pub const DOCUMENT_POSITION_CONTAINS = bridge.property(0x08);
pub const DOCUMENT_POSITION_CONTAINED_BY = bridge.property(0x10);
pub const DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = bridge.property(0x20);
pub const DOCUMENT_POSITION_DISCONNECTED = bridge.property(0x01, .{ .template = true });
pub const DOCUMENT_POSITION_PRECEDING = bridge.property(0x02, .{ .template = true });
pub const DOCUMENT_POSITION_FOLLOWING = bridge.property(0x04, .{ .template = true });
pub const DOCUMENT_POSITION_CONTAINS = bridge.property(0x08, .{ .template = true });
pub const DOCUMENT_POSITION_CONTAINED_BY = bridge.property(0x10, .{ .template = true });
pub const DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = bridge.property(0x20, .{ .template = true });
pub const nodeName = bridge.accessor(struct {
fn wrap(self: *const Node, page: *Page) []const u8 {

View File

@@ -67,7 +67,7 @@ pub const SHOW_NOTATION: u32 = 0x800;
pub fn acceptNode(self: *const NodeFilter, node: *Node, local: *const js.Local) !i32 {
const func = self._func orelse return FILTER_ACCEPT;
return local.toLocal(func).call(i32, .{node});
return local.toLocal(func).callRethrow(i32, .{node});
}
pub fn shouldShow(node: *const Node, what_to_show: u32) bool {
@@ -90,21 +90,21 @@ pub const JsApi = struct {
pub const empty_with_no_proto = true;
};
pub const FILTER_ACCEPT = bridge.property(NodeFilter.FILTER_ACCEPT);
pub const FILTER_REJECT = bridge.property(NodeFilter.FILTER_REJECT);
pub const FILTER_SKIP = bridge.property(NodeFilter.FILTER_SKIP);
pub const FILTER_ACCEPT = bridge.property(NodeFilter.FILTER_ACCEPT, .{ .template = true });
pub const FILTER_REJECT = bridge.property(NodeFilter.FILTER_REJECT, .{ .template = true });
pub const FILTER_SKIP = bridge.property(NodeFilter.FILTER_SKIP, .{ .template = true });
pub const SHOW_ALL = bridge.property(NodeFilter.SHOW_ALL);
pub const SHOW_ELEMENT = bridge.property(NodeFilter.SHOW_ELEMENT);
pub const SHOW_ATTRIBUTE = bridge.property(NodeFilter.SHOW_ATTRIBUTE);
pub const SHOW_TEXT = bridge.property(NodeFilter.SHOW_TEXT);
pub const SHOW_CDATA_SECTION = bridge.property(NodeFilter.SHOW_CDATA_SECTION);
pub const SHOW_ENTITY_REFERENCE = bridge.property(NodeFilter.SHOW_ENTITY_REFERENCE);
pub const SHOW_ENTITY = bridge.property(NodeFilter.SHOW_ENTITY);
pub const SHOW_PROCESSING_INSTRUCTION = bridge.property(NodeFilter.SHOW_PROCESSING_INSTRUCTION);
pub const SHOW_COMMENT = bridge.property(NodeFilter.SHOW_COMMENT);
pub const SHOW_DOCUMENT = bridge.property(NodeFilter.SHOW_DOCUMENT);
pub const SHOW_DOCUMENT_TYPE = bridge.property(NodeFilter.SHOW_DOCUMENT_TYPE);
pub const SHOW_DOCUMENT_FRAGMENT = bridge.property(NodeFilter.SHOW_DOCUMENT_FRAGMENT);
pub const SHOW_NOTATION = bridge.property(NodeFilter.SHOW_NOTATION);
pub const SHOW_ALL = bridge.property(NodeFilter.SHOW_ALL, .{ .template = true });
pub const SHOW_ELEMENT = bridge.property(NodeFilter.SHOW_ELEMENT, .{ .template = true });
pub const SHOW_ATTRIBUTE = bridge.property(NodeFilter.SHOW_ATTRIBUTE, .{ .template = true });
pub const SHOW_TEXT = bridge.property(NodeFilter.SHOW_TEXT, .{ .template = true });
pub const SHOW_CDATA_SECTION = bridge.property(NodeFilter.SHOW_CDATA_SECTION, .{ .template = true });
pub const SHOW_ENTITY_REFERENCE = bridge.property(NodeFilter.SHOW_ENTITY_REFERENCE, .{ .template = true });
pub const SHOW_ENTITY = bridge.property(NodeFilter.SHOW_ENTITY, .{ .template = true });
pub const SHOW_PROCESSING_INSTRUCTION = bridge.property(NodeFilter.SHOW_PROCESSING_INSTRUCTION, .{ .template = true });
pub const SHOW_COMMENT = bridge.property(NodeFilter.SHOW_COMMENT, .{ .template = true });
pub const SHOW_DOCUMENT = bridge.property(NodeFilter.SHOW_DOCUMENT, .{ .template = true });
pub const SHOW_DOCUMENT_TYPE = bridge.property(NodeFilter.SHOW_DOCUMENT_TYPE, .{ .template = true });
pub const SHOW_DOCUMENT_FRAGMENT = bridge.property(NodeFilter.SHOW_DOCUMENT_FRAGMENT, .{ .template = true });
pub const SHOW_NOTATION = bridge.property(NodeFilter.SHOW_NOTATION, .{ .template = true });
};

View File

@@ -0,0 +1,76 @@
// 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/js.zig");
pub fn registerTypes() []const type {
return &.{ PluginArray, Plugin };
}
const PluginArray = @This();
_pad: bool = false,
pub fn refresh(_: *const PluginArray) void {}
pub fn getAtIndex(_: *const PluginArray, index: usize) ?*Plugin {
_ = index;
return null;
}
pub fn getByName(_: *const PluginArray, name: []const u8) ?*Plugin {
_ = name;
return null;
}
// Cannot be constructed, and we currently never return any, so no reason to
// implement anything on it (for now)
const Plugin = struct {
pub const JsApi = struct {
pub const bridge = js.Bridge(Plugin);
pub const Meta = struct {
pub const name = "Plugin";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
};
};
pub const JsApi = struct {
pub const bridge = js.Bridge(PluginArray);
pub const Meta = struct {
pub const name = "PluginArray";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const length = bridge.property(0, .{ .template = false });
pub const refresh = bridge.function(PluginArray.refresh, .{});
pub const @"[int]" = bridge.indexed(PluginArray.getAtIndex, .{ .null_as_undefined = true });
pub const @"[str]" = bridge.namedIndexed(PluginArray.getByName, null, null, .{ .null_as_undefined = true });
pub const item = bridge.function(_item, .{});
fn _item(self: *const PluginArray, index: i32) ?*Plugin {
if (index < 0) {
return null;
}
return self.getAtIndex(@intCast(index));
}
pub const namedItem = bridge.function(PluginArray.getByName, .{});
};

View File

@@ -565,10 +565,10 @@ pub const JsApi = struct {
};
// Constants for compareBoundaryPoints
pub const START_TO_START = bridge.property(0);
pub const START_TO_END = bridge.property(1);
pub const END_TO_END = bridge.property(2);
pub const END_TO_START = bridge.property(3);
pub const START_TO_START = bridge.property(0, .{ .template = true });
pub const START_TO_END = bridge.property(1, .{ .template = true });
pub const END_TO_END = bridge.property(2, .{ .template = true });
pub const END_TO_START = bridge.property(3, .{ .template = true });
pub const constructor = bridge.constructor(Range.init, .{});
pub const setStart = bridge.function(Range.setStart, .{ .dom_exception = true });

View File

@@ -43,30 +43,6 @@ pub fn asEventTarget(self: *Screen) *EventTarget {
return self._proto;
}
pub fn getWidth(_: *const Screen) u32 {
return 1920;
}
pub fn getHeight(_: *const Screen) u32 {
return 1080;
}
pub fn getAvailWidth(_: *const Screen) u32 {
return 1920;
}
pub fn getAvailHeight(_: *const Screen) u32 {
return 1040; // 40px reserved for taskbar/dock
}
pub fn getColorDepth(_: *const Screen) u32 {
return 24;
}
pub fn getPixelDepth(_: *const Screen) u32 {
return 24;
}
pub fn getOrientation(self: *Screen, page: *Page) !*Orientation {
if (self._orientation) |orientation| {
return orientation;
@@ -85,12 +61,12 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const width = bridge.accessor(Screen.getWidth, null, .{});
pub const height = bridge.accessor(Screen.getHeight, null, .{});
pub const availWidth = bridge.accessor(Screen.getAvailWidth, null, .{});
pub const availHeight = bridge.accessor(Screen.getAvailHeight, null, .{});
pub const colorDepth = bridge.accessor(Screen.getColorDepth, null, .{});
pub const pixelDepth = bridge.accessor(Screen.getPixelDepth, null, .{});
pub const width = bridge.property(1920, .{ .template = false });
pub const height = bridge.property(1080, .{ .template = false });
pub const availWidth = bridge.property(1920, .{ .template = false });
pub const availHeight = bridge.property(1040, .{ .template = false });
pub const colorDepth = bridge.property(24, .{ .template = false });
pub const pixelDepth = bridge.property(24, .{ .template = false });
pub const orientation = bridge.accessor(Screen.getOrientation, null, .{});
};
@@ -107,14 +83,6 @@ pub const Orientation = struct {
return self._proto;
}
pub fn getAngle(_: *const Orientation) u32 {
return 0;
}
pub fn getType(_: *const Orientation) []const u8 {
return "landscape-primary";
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Orientation);
@@ -124,7 +92,7 @@ pub const Orientation = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const angle = bridge.accessor(Orientation.getAngle, null, .{});
pub const @"type" = bridge.accessor(Orientation.getType, null, .{});
pub const angle = bridge.property(0, .{ .template = false });
pub const @"type" = bridge.property("landscape-primary", .{ .template = false });
};
};

View File

@@ -24,6 +24,8 @@ const Page = @import("../Page.zig");
const Range = @import("Range.zig");
const AbstractRange = @import("AbstractRange.zig");
const Node = @import("Node.zig");
const Event = @import("Event.zig");
const Document = @import("Document.zig");
/// https://w3c.github.io/selection-api/
const Selection = @This();
@@ -35,6 +37,12 @@ _direction: SelectionDirection = .none,
pub const init: Selection = .{};
fn dispatchSelectionChangeEvent(page: *Page) !void {
const event = try Event.init("selectionchange", .{}, page);
defer if (!event._v8_handoff) event.deinit(false);
try page._event_manager.dispatch(page.document.asEventTarget(), event);
}
fn isInTree(self: *const Selection) bool {
if (self._range == null) return false;
const anchor_node = self.getAnchorNode() orelse return false;
@@ -110,23 +118,33 @@ pub fn getType(self: *const Selection) []const u8 {
return "Range";
}
pub fn addRange(self: *Selection, range: *Range) !void {
pub fn addRange(self: *Selection, range: *Range, page: *Page) !void {
if (self._range != null) return;
// Only add the range if its root node is in the document associated with this selection
const start_node = range.asAbstractRange().getStartContainer();
if (!page.document.asNode().contains(start_node)) {
return;
}
self._range = range;
try dispatchSelectionChangeEvent(page);
}
pub fn removeRange(self: *Selection, range: *Range) !void {
pub fn removeRange(self: *Selection, range: *Range, page: *Page) !void {
if (self._range == range) {
self._range = null;
try dispatchSelectionChangeEvent(page);
return;
} else {
return error.NotFound;
}
}
pub fn removeAllRanges(self: *Selection) void {
pub fn removeAllRanges(self: *Selection, page: *Page) !void {
self._range = null;
self._direction = .none;
try dispatchSelectionChangeEvent(page);
}
pub fn collapseToEnd(self: *Selection, page: *Page) !void {
@@ -142,10 +160,11 @@ pub fn collapseToEnd(self: *Selection, page: *Page) !void {
self._range = new_range;
self._direction = .none;
try dispatchSelectionChangeEvent(page);
}
pub fn collapseToStart(self: *Selection, page: *Page) !void {
const range = self._range orelse return;
const range = self._range orelse return error.InvalidStateError;
const abstract = range.asAbstractRange();
const first_node = abstract.getStartContainer();
@@ -157,6 +176,7 @@ pub fn collapseToStart(self: *Selection, page: *Page) !void {
self._range = new_range;
self._direction = .none;
try dispatchSelectionChangeEvent(page);
}
pub fn containsNode(self: *const Selection, node: *Node, partial: bool) !bool {
@@ -187,14 +207,21 @@ pub fn containsNode(self: *const Selection, node: *Node, partial: bool) !bool {
pub fn deleteFromDocument(self: *Selection, page: *Page) !void {
const range = self._range orelse return;
try range.deleteContents(page);
try dispatchSelectionChangeEvent(page);
}
pub fn extend(self: *Selection, node: *Node, _offset: ?u32, page: *Page) !void {
const range = self._range orelse return error.InvalidState;
const offset = _offset orelse 0;
// If the node is not contained in the document, do not change the selection
if (!page.document.asNode().contains(node)) {
return;
}
if (node._type == .document_type) return error.InvalidNodeType;
if (offset > node.getLength()) {
return error.IndexSizeError;
}
@@ -230,6 +257,7 @@ pub fn extend(self: *Selection, node: *Node, _offset: ?u32, page: *Page) !void {
}
self._range = new_range;
try dispatchSelectionChangeEvent(page);
}
pub fn getRangeAt(self: *Selection, index: u32) !*Range {
@@ -299,16 +327,22 @@ pub fn modify(
}
pub fn selectAllChildren(self: *Selection, parent: *Node, page: *Page) !void {
if (parent._type == .document_type) return error.InvalidNodeTypeError;
if (parent._type == .document_type) return error.InvalidNodeType;
// If the node is not contained in the document, do not change the selection
if (!page.document.asNode().contains(parent)) {
return;
}
const range = try Range.init(page);
try range.setStart(parent, 0);
const child_count = parent.getLength();
const child_count = parent.getChildrenCount();
try range.setEnd(parent, @intCast(child_count));
self._range = range;
self._direction = .forward;
try dispatchSelectionChangeEvent(page);
}
pub fn setBaseAndExtent(
@@ -355,11 +389,12 @@ pub fn setBaseAndExtent(
}
self._range = range;
try dispatchSelectionChangeEvent(page);
}
pub fn collapse(self: *Selection, _node: ?*Node, _offset: ?u32, page: *Page) !void {
const node = _node orelse {
self.removeAllRanges();
try self.removeAllRanges(page);
return;
};
@@ -370,12 +405,18 @@ pub fn collapse(self: *Selection, _node: ?*Node, _offset: ?u32, page: *Page) !vo
return error.IndexSizeError;
}
// If the node is not contained in the document, do not change the selection
if (!page.document.asNode().contains(node)) {
return;
}
const range = try Range.init(page);
try range.setStart(node, offset);
try range.setEnd(node, offset);
self._range = range;
self._direction = .none;
try dispatchSelectionChangeEvent(page);
}
pub fn toString(self: *const Selection, page: *Page) ![]const u8 {
@@ -404,7 +445,7 @@ pub const JsApi = struct {
pub const addRange = bridge.function(Selection.addRange, .{});
pub const collapse = bridge.function(Selection.collapse, .{ .dom_exception = true });
pub const collapseToEnd = bridge.function(Selection.collapseToEnd, .{});
pub const collapseToStart = bridge.function(Selection.collapseToStart, .{});
pub const collapseToStart = bridge.function(Selection.collapseToStart, .{ .dom_exception = true });
pub const containsNode = bridge.function(Selection.containsNode, .{});
pub const deleteFromDocument = bridge.function(Selection.deleteFromDocument, .{});
pub const empty = bridge.function(Selection.removeAllRanges, .{});
@@ -414,9 +455,9 @@ pub const JsApi = struct {
pub const modify = bridge.function(Selection.modify, .{});
pub const removeAllRanges = bridge.function(Selection.removeAllRanges, .{});
pub const removeRange = bridge.function(Selection.removeRange, .{ .dom_exception = true });
pub const selectAllChildren = bridge.function(Selection.selectAllChildren, .{});
pub const selectAllChildren = bridge.function(Selection.selectAllChildren, .{ .dom_exception = true });
pub const setBaseAndExtent = bridge.function(Selection.setBaseAndExtent, .{ .dom_exception = true });
pub const setPosition = bridge.function(Selection.collapse, .{});
pub const setPosition = bridge.function(Selection.collapse, .{ .dom_exception = true });
pub const toString = bridge.function(Selection.toString, .{});
};

View File

@@ -0,0 +1,64 @@
// 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/js.zig");
const Page = @import("../Page.zig");
const EventTarget = @import("EventTarget.zig");
const Window = @import("Window.zig");
const VisualViewport = @This();
_proto: *EventTarget,
pub fn init(page: *Page) !*VisualViewport {
return page._factory.eventTarget(VisualViewport{
._proto = undefined,
});
}
pub fn asEventTarget(self: *VisualViewport) *EventTarget {
return self._proto;
}
pub fn getPageLeft(_: *const VisualViewport, page: *Page) u32 {
return page.window.getScrollX();
}
pub fn getPageTop(_: *const VisualViewport, page: *Page) u32 {
return page.window.getScrollY();
}
pub const JsApi = struct {
pub const bridge = js.Bridge(VisualViewport);
pub const Meta = struct {
pub const name = "VisualViewport";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
// Static viewport properties for headless browser
// No pinch-zoom or mobile viewport, so values are straightforward
pub const offsetLeft = bridge.property(0, .{ .template = false });
pub const offsetTop = bridge.property(0, .{ .template = false });
pub const pageLeft = bridge.accessor(VisualViewport.getPageLeft, null, .{});
pub const pageTop = bridge.accessor(VisualViewport.getPageTop, null, .{});
pub const width = bridge.property(1920, .{ .template = false });
pub const height = bridge.property(1080, .{ .template = false });
pub const scale = bridge.property(1.0, .{ .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>
@@ -29,6 +29,7 @@ const Crypto = @import("Crypto.zig");
const CSS = @import("CSS.zig");
const Navigator = @import("Navigator.zig");
const Screen = @import("Screen.zig");
const VisualViewport = @import("VisualViewport.zig");
const Performance = @import("Performance.zig");
const Document = @import("Document.zig");
const Location = @import("Location.zig");
@@ -44,6 +45,10 @@ const CSSStyleProperties = @import("css/CSSStyleProperties.zig");
const CustomElementRegistry = @import("CustomElementRegistry.zig");
const Selection = @import("Selection.zig");
const IS_DEBUG = builtin.mode == .Debug;
const Allocator = std.mem.Allocator;
const Window = @This();
_proto: *EventTarget,
@@ -53,6 +58,7 @@ _crypto: Crypto = .init,
_console: Console = .init,
_navigator: Navigator = .init,
_screen: *Screen,
_visual_viewport: *VisualViewport,
_performance: Performance,
_storage_bucket: *storage.Bucket,
_on_load: ?js.Function.Global = null,
@@ -106,6 +112,10 @@ pub fn getScreen(self: *Window) *Screen {
return self._screen;
}
pub fn getVisualViewport(self: *const Window) *VisualViewport {
return self._visual_viewport;
}
pub fn getCrypto(self: *Window) *Crypto {
return &self._crypto;
}
@@ -275,14 +285,15 @@ pub fn cancelIdleCallback(self: *Window, id: u32) void {
}
pub fn reportError(self: *Window, err: js.Value, page: *Page) !void {
const error_event = try ErrorEvent.initTrusted("error", .{
.@"error" = try err.persist(),
.message = err.toString(.{}) catch "Unknown error",
const error_event = try ErrorEvent.initTrusted(comptime .wrap("error"), .{
.@"error" = try err.temp(),
.message = err.toStringSlice() catch "Unknown error",
.bubbles = false,
.cancelable = true,
}, page);
const event = error_event.asEvent();
defer if (!event._v8_handoff) event.deinit(false);
// Invoke window.onerror callback if set (per WHATWG spec, this is called
// with 5 arguments: message, source, lineno, colno, error)
@@ -339,32 +350,27 @@ pub fn getComputedStyle(_: *const Window, element: *Element, pseudo_element: ?[]
return CSSStyleProperties.init(element, true, page);
}
pub fn getIsSecureContext(_: *const Window) bool {
// Return false since we don't have secure-context-only APIs implemented
// (webcam, geolocation, clipboard, etc.)
// This is safer and could help avoid processing errors by hinting at
// sites not to try to access those features
return false;
}
pub fn postMessage(self: *Window, message: js.Value.Global, target_origin: ?[]const u8, page: *Page) !void {
pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]const u8, page: *Page) !void {
// For now, we ignore targetOrigin checking and just dispatch the message
// In a full implementation, we would validate the origin
_ = target_origin;
// postMessage queues a task (not a microtask), so use the scheduler
const origin = try self._location.getOrigin(page);
const callback = try page._factory.create(PostMessageCallback{
.window = self,
.message = message,
.origin = try page.arena.dupe(u8, origin),
.page = page,
});
errdefer page._factory.destroy(callback);
const arena = try page.getArena(.{ .debug = "Window.schedule" });
errdefer page.releaseArena(arena);
try page.scheduler.add(callback, PostMessageCallback.run, 0, .{
const origin = try self._location.getOrigin(page);
const callback = try arena.create(PostMessageCallback);
callback.* = .{
.page = page,
.arena = arena,
.message = message,
.origin = try arena.dupe(u8, origin),
};
try page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{
.name = "postMessage",
.low_priority = false,
.finalizer = PostMessageCallback.cancelled,
});
}
@@ -375,9 +381,10 @@ pub fn btoa(_: *const Window, input: []const u8, page: *Page) ![]const u8 {
}
pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 {
const decoded_len = std.base64.standard.Decoder.calcSizeForSlice(input) catch return error.InvalidCharacterError;
const trimmed = std.mem.trim(u8, input, &std.ascii.whitespace);
const decoded_len = std.base64.standard.Decoder.calcSizeForSlice(trimmed) catch return error.InvalidCharacterError;
const decoded = try page.call_arena.alloc(u8, decoded_len);
std.base64.standard.Decoder.decode(decoded, input) catch return error.InvalidCharacterError;
std.base64.standard.Decoder.decode(decoded, trimmed) catch return error.InvalidCharacterError;
return decoded;
}
@@ -400,14 +407,6 @@ pub fn getFramesLength(self: *const Window) u32 {
return ln;
}
pub fn getInnerWidth(_: *const Window) u32 {
return 1920;
}
pub fn getInnerHeight(_: *const Window) u32 {
return 1080;
}
pub fn getScrollX(self: *const Window) u32 {
return self._scroll_pos.x;
}
@@ -442,7 +441,7 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void {
// We dispatch scroll event asynchronously after 10ms. So we can throttle
// them.
try page.scheduler.add(
try page.js.scheduler.add(
page,
struct {
fn dispatch(_page: *anyopaque) anyerror!?u32 {
@@ -454,7 +453,8 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void {
return null;
}
const event = try Event.initTrusted("scroll", .{ .bubbles = true }, p);
const event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, p);
defer if (!event._v8_handoff) event.deinit(false);
try p._event_manager.dispatch(p.document.asEventTarget(), event);
pos.state = .end;
@@ -466,7 +466,7 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void {
.{ .low_priority = true },
);
// We dispatch scrollend event asynchronously after 20ms.
try page.scheduler.add(
try page.js.scheduler.add(
page,
struct {
fn dispatch(_page: *anyopaque) anyerror!?u32 {
@@ -481,7 +481,8 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void {
.end => {},
.done => return null,
}
const event = try Event.initTrusted("scrollend", .{ .bubbles = true }, p);
const event = try Event.initTrusted(comptime .wrap("scrollend"), .{ .bubbles = true }, p);
defer if (!event._v8_handoff) event.deinit(false);
try p._event_manager.dispatch(p.document.asEventTarget(), event);
pos.state = .done;
@@ -494,6 +495,28 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void {
);
}
pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection, page: *Page) !void {
if (comptime IS_DEBUG) {
log.debug(.js, "unhandled rejection", .{
.value = rejection.reason(),
.stack = rejection.local.stackTrace() catch |err| @errorName(err) orelse "???",
});
}
var event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{
.reason = if (rejection.reason()) |r| try r.temp() else null,
.promise = try rejection.promise().temp(),
}, page)).asEvent();
defer if (!event._v8_handoff) event.deinit(false);
try page._event_manager.dispatchWithFunction(
self.asEventTarget(),
event,
rejection.local.toLocal(self._on_unhandled_rejection),
.{ .inject_target = true, .context = "window.unhandledrejection" },
);
}
const ScheduleOpts = struct {
repeat: bool,
params: []js.Value.Temp,
@@ -508,13 +531,16 @@ fn scheduleCallback(self: *Window, cb: js.Function.Temp, delay_ms: u32, opts: Sc
return error.TooManyTimeout;
}
const arena = try page.getArena(.{ .debug = "Window.schedule" });
errdefer page.releaseArena(arena);
const timer_id = self._timer_id +% 1;
self._timer_id = timer_id;
const params = opts.params;
var persisted_params: []js.Value.Temp = &.{};
if (params.len > 0) {
persisted_params = try page.arena.dupe(js.Value.Temp, params);
persisted_params = try arena.dupe(js.Value.Temp, params);
}
const gop = try self._timers.getOrPut(page.arena, timer_id);
@@ -524,21 +550,23 @@ fn scheduleCallback(self: *Window, cb: js.Function.Temp, delay_ms: u32, opts: Sc
}
errdefer _ = self._timers.remove(timer_id);
const callback = try page._factory.create(ScheduleCallback{
const callback = try arena.create(ScheduleCallback);
callback.* = .{
.cb = cb,
.page = page,
.arena = arena,
.mode = opts.mode,
.name = opts.name,
.timer_id = timer_id,
.params = persisted_params,
.repeat_ms = if (opts.repeat) if (delay_ms == 0) 1 else delay_ms else null,
});
};
gop.value_ptr.* = callback;
errdefer page._factory.destroy(callback);
try page.scheduler.add(callback, ScheduleCallback.run, delay_ms, .{
try page.js.scheduler.add(callback, ScheduleCallback.run, delay_ms, .{
.name = opts.name,
.low_priority = opts.low_priority,
.finalizer = ScheduleCallback.cancelled,
});
return timer_id;
@@ -556,13 +584,11 @@ const ScheduleCallback = struct {
cb: js.Function.Temp,
page: *Page,
params: []const js.Value.Temp,
removed: bool = false,
mode: Mode,
page: *Page,
arena: Allocator,
removed: bool = false,
params: []const js.Value.Temp,
const Mode = enum {
idle,
@@ -570,19 +596,26 @@ const ScheduleCallback = struct {
animation_frame,
};
fn cancelled(ctx: *anyopaque) void {
var self: *ScheduleCallback = @ptrCast(@alignCast(ctx));
self.deinit();
}
fn deinit(self: *ScheduleCallback) void {
self.page.js.release(self.cb);
for (self.params) |param| {
self.page.js.release(param);
}
self.page._factory.destroy(self);
self.page.releaseArena(self.arena);
}
fn run(ctx: *anyopaque) !?u32 {
const self: *ScheduleCallback = @ptrCast(@alignCast(ctx));
const page = self.page;
const window = page.window;
if (self.removed) {
_ = page.window._timers.remove(self.timer_id);
_ = window._timers.remove(self.timer_id);
self.deinit();
return null;
}
@@ -599,7 +632,7 @@ const ScheduleCallback = struct {
};
},
.animation_frame => {
ls.toLocal(self.cb).call(void, .{page.window._performance.now()}) catch |err| {
ls.toLocal(self.cb).call(void, .{window._performance.now()}) catch |err| {
log.warn(.js, "window.RAF", .{ .name = self.name, .err = err });
};
},
@@ -614,35 +647,42 @@ const ScheduleCallback = struct {
return ms;
}
defer self.deinit();
_ = page.window._timers.remove(self.timer_id);
_ = window._timers.remove(self.timer_id);
return null;
}
};
const PostMessageCallback = struct {
window: *Window,
message: js.Value.Global,
origin: []const u8,
page: *Page,
arena: Allocator,
origin: []const u8,
message: js.Value.Temp,
fn deinit(self: *PostMessageCallback) void {
self.page._factory.destroy(self);
self.page.releaseArena(self.arena);
}
fn cancelled(ctx: *anyopaque) void {
const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
self.page.releaseArena(self.arena);
}
fn run(ctx: *anyopaque) !?u32 {
const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
defer self.deinit();
const message_event = try MessageEvent.initTrusted("message", .{
const page = self.page;
const window = page.window;
const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = self.message,
.origin = self.origin,
.source = self.window,
.source = window,
.bubbles = false,
.cancelable = false,
}, self.page);
const event = message_event.asEvent();
try self.page._event_manager.dispatch(self.window.asEventTarget(), event);
}, page)).asEvent();
defer if (!event._v8_handoff) event.deinit(false);
try page._event_manager.dispatch(window.asEventTarget(), event);
return null;
}
@@ -673,23 +713,24 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const top = bridge.accessor(Window.getWindow, null, .{ .cache = "top" });
pub const self = bridge.accessor(Window.getWindow, null, .{ .cache = "self" });
pub const window = bridge.accessor(Window.getWindow, null, .{ .cache = "window" });
pub const parent = bridge.accessor(Window.getWindow, null, .{ .cache = "parent" });
pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = "console" });
pub const navigator = bridge.accessor(Window.getNavigator, null, .{ .cache = "navigator" });
pub const screen = bridge.accessor(Window.getScreen, null, .{ .cache = "screen" });
pub const performance = bridge.accessor(Window.getPerformance, null, .{ .cache = "performance" });
pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{ .cache = "localStorage" });
pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{ .cache = "sessionStorage" });
pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = "document" });
pub const top = bridge.accessor(Window.getWindow, null, .{});
pub const self = bridge.accessor(Window.getWindow, null, .{});
pub const window = bridge.accessor(Window.getWindow, null, .{});
pub const parent = bridge.accessor(Window.getWindow, null, .{});
pub const console = bridge.accessor(Window.getConsole, null, .{});
pub const navigator = bridge.accessor(Window.getNavigator, null, .{});
pub const screen = bridge.accessor(Window.getScreen, null, .{});
pub const visualViewport = bridge.accessor(Window.getVisualViewport, null, .{});
pub const performance = bridge.accessor(Window.getPerformance, null, .{});
pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{});
pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{});
pub const document = bridge.accessor(Window.getDocument, null, .{});
pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{});
pub const history = bridge.accessor(Window.getHistory, null, .{});
pub const navigation = bridge.accessor(Window.getNavigation, null, .{});
pub const crypto = bridge.accessor(Window.getCrypto, null, .{ .cache = "crypto" });
pub const CSS = bridge.accessor(Window.getCSS, null, .{ .cache = "CSS" });
pub const customElements = bridge.accessor(Window.getCustomElements, null, .{ .cache = "customElements" });
pub const crypto = bridge.accessor(Window.getCrypto, null, .{});
pub const CSS = bridge.accessor(Window.getCSS, null, .{});
pub const customElements = bridge.accessor(Window.getCustomElements, null, .{});
pub const onload = bridge.accessor(Window.getOnLoad, Window.setOnLoad, .{});
pub const onpageshow = bridge.accessor(Window.getOnPageShow, Window.setOnPageShow, .{});
pub const onpopstate = bridge.accessor(Window.getOnPopState, Window.setOnPopState, .{});
@@ -714,18 +755,28 @@ pub const JsApi = struct {
pub const reportError = bridge.function(Window.reportError, .{});
pub const getComputedStyle = bridge.function(Window.getComputedStyle, .{});
pub const getSelection = bridge.function(Window.getSelection, .{});
pub const isSecureContext = bridge.accessor(Window.getIsSecureContext, null, .{});
pub const frames = bridge.accessor(Window.getWindow, null, .{ .cache = "frames" });
pub const frames = bridge.accessor(Window.getWindow, null, .{});
pub const index = bridge.indexed(Window.getFrame, .{ .null_as_undefined = true });
pub const length = bridge.accessor(Window.getFramesLength, null, .{ .cache = "length" });
pub const innerWidth = bridge.accessor(Window.getInnerWidth, null, .{ .cache = "innerWidth" });
pub const innerHeight = bridge.accessor(Window.getInnerHeight, null, .{ .cache = "innerHeight" });
pub const scrollX = bridge.accessor(Window.getScrollX, null, .{ .cache = "scrollX" });
pub const scrollY = bridge.accessor(Window.getScrollY, null, .{ .cache = "scrollY" });
pub const pageXOffset = bridge.accessor(Window.getScrollX, null, .{ .cache = "pageXOffset" });
pub const pageYOffset = bridge.accessor(Window.getScrollY, null, .{ .cache = "pageYOffset" });
pub const length = bridge.accessor(Window.getFramesLength, null, .{});
pub const scrollX = bridge.accessor(Window.getScrollX, null, .{});
pub const scrollY = bridge.accessor(Window.getScrollY, null, .{});
pub const pageXOffset = bridge.accessor(Window.getScrollX, null, .{});
pub const pageYOffset = bridge.accessor(Window.getScrollY, null, .{});
pub const scrollTo = bridge.function(Window.scrollTo, .{});
pub const scroll = bridge.function(Window.scrollTo, .{});
// Return false since we don't have secure-context-only APIs implemented
// (webcam, geolocation, clipboard, etc.)
// This is safer and could help avoid processing errors by hinting at
// sites not to try to access those features
pub const isSecureContext = bridge.property(false, .{ .template = false });
pub const innerWidth = bridge.property(1920, .{ .template = false });
pub const innerHeight = bridge.property(1080, .{ .template = false });
// This should return a window-like object in specific conditions. Would be
// pretty complicated to properly support I think.
pub const opener = bridge.property(null, .{ .template = false });
};
const testing = @import("../../testing.zig");

View File

@@ -36,14 +36,6 @@ pub fn cancel(_: *Animation) void {}
pub fn finish(_: *Animation) void {}
pub fn reverse(_: *Animation) void {}
pub fn getPlayState(_: *const Animation) []const u8 {
return "finished";
}
pub fn getPending(_: *const Animation) bool {
return false;
}
pub fn getFinished(self: *Animation, page: *Page) !js.Promise {
if (self._finished_resolver == null) {
const resolver = page.js.local.?.createPromiseResolver();
@@ -94,8 +86,8 @@ pub const JsApi = struct {
pub const cancel = bridge.function(Animation.cancel, .{});
pub const finish = bridge.function(Animation.finish, .{});
pub const reverse = bridge.function(Animation.reverse, .{});
pub const playState = bridge.accessor(Animation.getPlayState, null, .{});
pub const pending = bridge.accessor(Animation.getPending, null, .{});
pub const playState = bridge.property("finished", .{ .template = false });
pub const pending = bridge.property(false, .{ .template = false });
pub const finished = bridge.accessor(Animation.getFinished, null, .{});
pub const ready = bridge.accessor(Animation.getReady, null, .{});
pub const effect = bridge.accessor(Animation.getEffect, Animation.setEffect, .{});

View File

@@ -218,7 +218,7 @@ fn getDefaultDisplay(element: *const Element) []const u8 {
.html => |html| {
return switch (html._type) {
.anchor, .br, .span, .label, .time, .font, .mod, .quote => "inline",
.body, .div, .p, .heading, .form, .button, .canvas, .dialog, .embed, .head, .html, .hr, .iframe, .img, .input, .li, .link, .meta, .ol, .option, .script, .select, .slot, .style, .template, .textarea, .title, .ul, .media, .area, .base, .datalist, .directory, .fieldset, .legend, .map, .meter, .object, .optgroup, .output, .param, .pre, .progress, .source, .table, .table_caption, .table_cell, .table_col, .table_row, .table_section, .track => "block",
.body, .div, .p, .heading, .form, .button, .canvas, .dialog, .embed, .head, .html, .hr, .iframe, .img, .input, .li, .link, .meta, .ol, .option, .script, .select, .slot, .style, .template, .textarea, .title, .ul, .media, .area, .base, .datalist, .directory, .fieldset, .legend, .map, .meter, .object, .optgroup, .output, .param, .picture, .pre, .progress, .source, .table, .table_caption, .table_cell, .table_col, .table_row, .table_section, .track => "block",
.generic, .custom, .unknown, .data => blk: {
const tag = element.getTagNameLower();
if (isInlineTag(tag)) break :blk "inline";

File diff suppressed because it is too large Load Diff

View File

@@ -241,7 +241,6 @@ pub const JsApi = struct {
pub const bridge = js.Bridge(Custom);
pub const Meta = struct {
pub const name = "TODO-CUSTOM-NAME";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};

View File

@@ -32,6 +32,9 @@ pub const TextArea = @import("TextArea.zig");
const Form = @This();
_proto: *HtmlElement,
pub fn asHtmlElement(self: *Form) *HtmlElement {
return self._proto;
}
fn asConstElement(self: *const Form) *const Element {
return self._proto._proto;
}
@@ -88,7 +91,7 @@ pub fn getLength(self: *Form, page: *Page) !u32 {
}
pub fn submit(self: *Form, page: *Page) !void {
return page.submitForm(null, self);
return page.submitForm(null, self, .{ .fire_event = false });
}
pub const JsApi = struct {

View File

@@ -5,6 +5,10 @@ const URL = @import("../../../URL.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
const Event = @import("../../Event.zig");
const log = @import("../../../../log.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
const Image = @This();
_proto: *HtmlElement,
@@ -96,6 +100,18 @@ pub fn setLoading(self: *Image, value: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe(comptime .wrap("loading"), .wrap(value), page);
}
pub fn getNaturalWidth(_: *const Image) u32 {
// this is a valid response under a number of normal conditions, but could
// be used to detect the nature of Browser.
return 0;
}
pub fn getNaturalHeight(_: *const Image) u32 {
// this is a valid response under a number of normal conditions, but could
// be used to detect the nature of Browser.
return 0;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Image);
@@ -113,6 +129,21 @@ pub const JsApi = struct {
pub const height = bridge.accessor(Image.getHeight, Image.setHeight, .{});
pub const crossOrigin = bridge.accessor(Image.getCrossOrigin, Image.setCrossOrigin, .{});
pub const loading = bridge.accessor(Image.getLoading, Image.setLoading, .{});
pub const naturalWidth = bridge.accessor(Image.getNaturalWidth, null, .{});
pub const naturalHeight = bridge.accessor(Image.getNaturalHeight, null, .{});
};
pub const Build = struct {
pub fn created(node: *Node, page: *Page) !void {
const self = node.as(Image);
const image = self.asElement();
// Exit if src not set.
// TODO: We might want to check if src point to valid image.
_ = image.getAttributeSafe(comptime .wrap("src")) orelse return;
// Push to `_to_load` to dispatch load event just before window load event.
return page._to_load.append(page.arena, image);
}
};
const testing = @import("../../../../testing.zig");

View File

@@ -27,6 +27,7 @@ const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
const Form = @import("Form.zig");
const Selection = @import("../../Selection.zig");
const Event = @import("../../Event.zig");
const Input = @This();
@@ -83,6 +84,26 @@ _selection_start: u32 = 0,
_selection_end: u32 = 0,
_selection_direction: Selection.SelectionDirection = .none,
_on_selectionchange: ?js.Function.Global = null,
pub fn getOnSelectionChange(self: *Input) ?js.Function.Global {
return self._on_selectionchange;
}
pub fn setOnSelectionChange(self: *Input, listener: ?js.Function) !void {
if (listener) |listen| {
self._on_selectionchange = try listen.persistWithThis(self);
} else {
self._on_selectionchange = null;
}
}
fn dispatchSelectionChangeEvent(self: *Input, page: *Page) !void {
const event = try Event.init("selectionchange", .{ .bubbles = true }, page);
defer if (!event._v8_handoff) event.deinit(false);
try page._event_manager.dispatch(self.asElement().asEventTarget(), event);
}
pub fn asElement(self: *Input) *Element {
return self._proto._proto;
}
@@ -108,8 +129,13 @@ pub fn getValue(self: *const Input) []const u8 {
}
pub fn setValue(self: *Input, value: []const u8, page: *Page) !void {
// This should _not_ call setAttribute. It updates the default state only
const owned = try page.dupeString(value);
// File inputs cannot have their value set programmatically for security reasons
if (self._input_type == .file) {
return error.InvalidStateError;
}
// This should _not_ call setAttribute. It updates the current state only
const sanitized = try self.sanitizeValue(value, page);
const owned = try page.dupeString(sanitized);
self._value = owned;
}
@@ -261,9 +287,9 @@ pub fn setRequired(self: *Input, required: bool, page: *Page) !void {
}
}
pub fn select(self: *Input) !void {
pub fn select(self: *Input, page: *Page) !void {
const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0;
try self.setSelectionRange(0, len, null);
try self.setSelectionRange(0, len, null, page);
}
fn selectionAvailable(self: *const Input) bool {
@@ -295,6 +321,7 @@ pub fn innerInsert(self: *Input, str: []const u8, page: *Page) !void {
self._selection_start = @intCast(new_value.len);
self._selection_end = @intCast(new_value.len);
self._selection_direction = .none;
try self.dispatchSelectionChangeEvent(page);
},
.partial => |range| {
// if the input is partially selected, replace the selected content.
@@ -313,6 +340,7 @@ pub fn innerInsert(self: *Input, str: []const u8, page: *Page) !void {
self._selection_start = @intCast(new_pos);
self._selection_end = @intCast(new_pos);
self._selection_direction = .none;
try self.dispatchSelectionChangeEvent(page);
},
.none => {
// if the input is not selected, just insert at cursor.
@@ -332,9 +360,10 @@ pub fn getSelectionStart(self: *const Input) !?u32 {
return self._selection_start;
}
pub fn setSelectionStart(self: *Input, value: u32) !void {
pub fn setSelectionStart(self: *Input, value: u32, page: *Page) !void {
if (!self.selectionAvailable()) return error.InvalidStateError;
self._selection_start = value;
try self.dispatchSelectionChangeEvent(page);
}
pub fn getSelectionEnd(self: *const Input) !?u32 {
@@ -342,12 +371,19 @@ pub fn getSelectionEnd(self: *const Input) !?u32 {
return self._selection_end;
}
pub fn setSelectionEnd(self: *Input, value: u32) !void {
pub fn setSelectionEnd(self: *Input, value: u32, page: *Page) !void {
if (!self.selectionAvailable()) return error.InvalidStateError;
self._selection_end = value;
try self.dispatchSelectionChangeEvent(page);
}
pub fn setSelectionRange(self: *Input, selection_start: u32, selection_end: u32, selection_dir: ?[]const u8) !void {
pub fn setSelectionRange(
self: *Input,
selection_start: u32,
selection_end: u32,
selection_dir: ?[]const u8,
page: *Page,
) !void {
if (!self.selectionAvailable()) return error.InvalidStateError;
const direction = blk: {
@@ -375,6 +411,8 @@ pub fn setSelectionRange(self: *Input, selection_start: u32, selection_end: u32,
self._selection_direction = direction;
self._selection_start = start;
self._selection_end = end;
try self.dispatchSelectionChangeEvent(page);
}
pub fn getForm(self: *Input, page: *Page) ?*Form {
@@ -401,6 +439,53 @@ pub fn getForm(self: *Input, page: *Page) ?*Form {
return null;
}
/// Sanitize the value according to the current input type
fn sanitizeValue(self: *Input, value: []const u8, page: *Page) ![]const u8 {
switch (self._input_type) {
.text, .search, .tel, .password, .url, .email => {
var i: usize = 0;
const result = try page.call_arena.alloc(u8, value.len);
for (value) |c| {
if (c != '\r' and c != '\n') {
result[i] = c;
i += 1;
}
}
const sanitized = result[0..i];
return switch (self._input_type) {
.url, .email => std.mem.trim(u8, sanitized, &std.ascii.whitespace),
else => sanitized,
};
},
.date, .time, .@"datetime-local", .month, .week => {
// TODO, we should sanitize this, but lack the necessary functions
// datetime.zig could handle date and time, but not the other three
// for now, allow al values.
return value;
},
.number => {
_ = std.fmt.parseFloat(f64, value) catch return "";
return value;
},
.range => {
// Range: default to "50" if invalid
_ = std.fmt.parseFloat(f64, value) catch return "50";
return value;
},
.color => {
if (value.len == 7 and value[0] == '#') {
for (value[1..]) |c| {
if (!std.ascii.isHex(c)) return "#000000";
}
return value;
}
return "#000000";
},
.file => return "", // File: always empty
.checkbox, .radio, .submit, .image, .reset, .button, .hidden => return value, // no sanitization
}
}
fn uncheckRadioGroup(self: *Input, page: *Page) !void {
const element = self.asElement();
@@ -453,8 +538,9 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const onselectionchange = bridge.accessor(Input.getOnSelectionChange, Input.setOnSelectionChange, .{});
pub const @"type" = bridge.accessor(Input.getType, Input.setType, .{});
pub const value = bridge.accessor(Input.getValue, Input.setValue, .{});
pub const value = bridge.accessor(Input.getValue, Input.setValue, .{ .dom_exception = true });
pub const defaultValue = bridge.accessor(Input.getDefaultValue, Input.setDefaultValue, .{});
pub const checked = bridge.accessor(Input.getChecked, Input.setChecked, .{});
pub const defaultChecked = bridge.accessor(Input.getDefaultChecked, Input.setDefaultChecked, .{});
@@ -505,7 +591,17 @@ pub const Build = struct {
const attribute = std.meta.stringToEnum(enum { type, value, checked }, name.str()) orelse return;
const self = element.as(Input);
switch (attribute) {
.type => self._input_type = Type.fromString(value.str()),
.type => {
self._input_type = Type.fromString(value.str());
// Sanitize the current value according to the new type
if (self._value) |current_value| {
self._value = try self.sanitizeValue(current_value, page);
// Apply default value for checkbox/radio if value is now empty
if (self._value.?.len == 0 and (self._input_type == .checkbox or self._input_type == .radio)) {
self._value = "on";
}
}
},
.value => self._default_value = try page.arena.dupe(u8, value.str()),
.checked => {
self._default_checked = true;

View File

@@ -30,6 +30,9 @@ _proto: *HtmlElement,
pub fn asElement(self: *Link) *Element {
return self._proto._proto;
}
pub fn asConstElement(self: *const Link) *const Element {
return self._proto._proto;
}
pub fn asNode(self: *Link) *Node {
return self.asElement().asNode();
}
@@ -57,6 +60,14 @@ pub fn setRel(self: *Link, value: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe(comptime .wrap("rel"), .wrap(value), page);
}
pub fn getAs(self: *const Link) []const u8 {
return self.asConstElement().getAttributeSafe(comptime .wrap("as")) orelse "";
}
pub fn setAs(self: *Link, value: []const u8, page: *Page) !void {
return self.asElement().setAttributeSafe(comptime .wrap("as"), .wrap(value), page);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Link);
@@ -66,6 +77,7 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const as = bridge.accessor(Link.getAs, Link.setAs, .{});
pub const rel = bridge.accessor(Link.getRel, Link.setRel, .{});
pub const href = bridge.accessor(Link.getHref, Link.setHref, .{});
pub const relList = bridge.accessor(_getRelList, null, .{ .null_as_undefined = true });

View File

@@ -284,16 +284,16 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const NETWORK_EMPTY = bridge.property(@intFromEnum(NetworkState.NETWORK_EMPTY));
pub const NETWORK_IDLE = bridge.property(@intFromEnum(NetworkState.NETWORK_IDLE));
pub const NETWORK_LOADING = bridge.property(@intFromEnum(NetworkState.NETWORK_LOADING));
pub const NETWORK_NO_SOURCE = bridge.property(@intFromEnum(NetworkState.NETWORK_NO_SOURCE));
pub const NETWORK_EMPTY = bridge.property(@intFromEnum(NetworkState.NETWORK_EMPTY), .{ .template = true });
pub const NETWORK_IDLE = bridge.property(@intFromEnum(NetworkState.NETWORK_IDLE), .{ .template = true });
pub const NETWORK_LOADING = bridge.property(@intFromEnum(NetworkState.NETWORK_LOADING), .{ .template = true });
pub const NETWORK_NO_SOURCE = bridge.property(@intFromEnum(NetworkState.NETWORK_NO_SOURCE), .{ .template = true });
pub const HAVE_NOTHING = bridge.property(@intFromEnum(ReadyState.HAVE_NOTHING));
pub const HAVE_METADATA = bridge.property(@intFromEnum(ReadyState.HAVE_METADATA));
pub const HAVE_CURRENT_DATA = bridge.property(@intFromEnum(ReadyState.HAVE_CURRENT_DATA));
pub const HAVE_FUTURE_DATA = bridge.property(@intFromEnum(ReadyState.HAVE_FUTURE_DATA));
pub const HAVE_ENOUGH_DATA = bridge.property(@intFromEnum(ReadyState.HAVE_ENOUGH_DATA));
pub const HAVE_NOTHING = bridge.property(@intFromEnum(ReadyState.HAVE_NOTHING), .{ .template = true });
pub const HAVE_METADATA = bridge.property(@intFromEnum(ReadyState.HAVE_METADATA), .{ .template = true });
pub const HAVE_CURRENT_DATA = bridge.property(@intFromEnum(ReadyState.HAVE_CURRENT_DATA), .{ .template = true });
pub const HAVE_FUTURE_DATA = bridge.property(@intFromEnum(ReadyState.HAVE_FUTURE_DATA), .{ .template = true });
pub const HAVE_ENOUGH_DATA = bridge.property(@intFromEnum(ReadyState.HAVE_ENOUGH_DATA), .{ .template = true });
pub const src = bridge.accessor(Media.getSrc, Media.setSrc, .{});
pub const autoplay = bridge.accessor(Media.getAutoplay, Media.setAutoplay, .{});

View File

@@ -0,0 +1,30 @@
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
const Picture = @This();
_proto: *HtmlElement,
pub fn asElement(self: *Picture) *Element {
return self._proto._proto;
}
pub fn asNode(self: *Picture) *Node {
return self.asElement().asNode();
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Picture);
pub const Meta = struct {
pub const name = "HTMLPictureElement";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
};
const testing = @import("../../../../testing.zig");
test "WebApi: Picture" {
try testing.htmlRunner("element/html/picture.html", .{});
}

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