585 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

remove `hasListener`

let `Scheduler` dispatch `load` event

Simulates async nature.

update test

free `args` when done

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

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

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

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

add `onload` getter/setter for `Image`

prefer `attributeChange` to run side-effects

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The original PR had:

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

But now the code is:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The global setup is now done once per isolate, rather than once per context.
2026-01-20 17:21:45 +08:00
Karl Seguin
6f3cb4b48e Merge pull request #1385 from lightpanda-io/remove_debug_print
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Remove a debug print
2026-01-20 16:36:26 +08:00
Karl Seguin
fbd047599e Merge pull request #1374 from lightpanda-io/fix_context_lifetime
Fix context lifetime
2026-01-20 16:24:12 +08:00
Karl Seguin
da00117622 Remove a debug print 2026-01-20 16:23:22 +08:00
Karl Seguin
e44c73bdf6 Merge pull request #1384 from lightpanda-io/htmlscript-src-absolute
`HTMLScriptElement` should return an absolute URL in `src`
2026-01-20 12:38:42 +08:00
Karl Seguin
e3cb7bd9f0 add test 2026-01-20 11:14:20 +08:00
Muki Kiboigo
08f5889ee5 getSrc should return an absolute URL 2026-01-19 18:50:24 -08:00
Muki Kiboigo
d5bfe74e1a add selection api to HTMLTextAreaElement 2026-01-19 18:37:52 -08:00
Muki Kiboigo
d7015fa3b6 add selection api to HTMLInputElement 2026-01-19 18:34:02 -08:00
Karl Seguin
9092651b5b Merge branch 'main' into fix_context_lifetime 2026-01-20 08:50:41 +08:00
Karl Seguin
2c53b48e0a add missing handlescope 2026-01-20 08:11:38 +08:00
Muki Kiboigo
319a1c3367 update WPT to include Selection 2026-01-19 07:12:40 -08:00
Muki Kiboigo
80dd590e8f add toString to Selection 2026-01-19 07:12:40 -08:00
Muki Kiboigo
992a8e8774 handle null anchor or focus nodes in Selection 2026-01-19 07:12:40 -08:00
Muki Kiboigo
f56d3bd193 do not modify old range in collapseToX 2026-01-19 07:12:40 -08:00
Muki Kiboigo
4ecc59d0c0 Fix a lot of Selection Issues
This uses the Chrome/Safari approach of only
having a single Range exist in the Selection.
This also better follows the W3C spec of Selection
2026-01-19 07:12:40 -08:00
Muki Kiboigo
5ebf82874b fix selection test inconsistency 2026-01-19 07:12:40 -08:00
Muki Kiboigo
12670a3153 fix extend direction in Selection 2026-01-19 07:12:40 -08:00
Muki Kiboigo
fa3a23134e properly return NotFoundError on removeRange 2026-01-19 07:12:39 -08:00
Muki Kiboigo
8291044abc fix collapseToStart on Selection 2026-01-19 07:12:39 -08:00
Muki Kiboigo
505e0799da add remaining functions to Selection 2026-01-19 07:12:39 -08:00
Muki Kiboigo
be1d463775 add Selection WebAPI test 2026-01-19 07:12:39 -08:00
Muki Kiboigo
a6fc5aa345 add getSelection to Window, Document 2026-01-19 07:12:37 -08:00
Muki Kiboigo
0e6e4db08b add Selection WebAPI 2026-01-19 07:11:45 -08:00
Karl Seguin
a84708e99d Merge pull request #1359 from lightpanda-io/crash_handler
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
Improve crash handling
2026-01-19 16:50:08 +08:00
Halil Durak
6b6c0e930e Merge pull request #1376 from lightpanda-io/nikneym/attribute-ns
Add simplified `setAttributeNS` and `getAttributeNS`
2026-01-19 11:08:49 +03:00
Halil Durak
926892be01 add not_implemented warnings 2026-01-19 10:57:48 +03:00
Karl Seguin
2894bef9ef Update src/crash_handler.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2026-01-19 15:06:43 +08:00
Karl Seguin
a6e7ecd9e5 Move more asserts to custom asserter.
Deciding what should be an lp.assert, vs an std.debug.assert, vs a debug-only
assert is a little arbitrary.

debug-only asserts, guarded with an `if (comptime IS_DEBUG)` obviously avoid the
check in release and thus have a performance advantage. We also use them at
library boundaries. If libcurl says it will always emit a header line with a
trailing \r\n, is that really a check we need to do in production? I don't think
so. First, that code path is checked _a lot_ in debug. Second, it feels a bit
like we're testing libcurl (in production!)..why? A debug-only assertion should
be good enough to catch any changes in libcurl.
2026-01-19 09:12:16 +08:00
Karl Seguin
9b000a002e Hook v8 crashes into new crash handler 2026-01-19 07:37:10 +08:00
Karl Seguin
0f9c9e2089 Improve crash handling
This adds a crash handler which reports a crash (if telemetry is enabled). On a
crash, this looks for `curl` (using the PATH env), and forks the process to then
call execve. This relies on a new endpoint to be setup to accept the "report".
Also, we include very little data..I figured just knowing about crashes would
be a good place to start.

A panic handler is provided, which override's Zig default handler and hooks
into the crash handler.

An `assert` function is added and hooks into the crash handler. This is
currently only used in one place (Session.zig) to demonstrate its use. In
addition to reporting a failed assert, the assert aborts execution in
ReleaseFast (as opposed to an undefined behavior with std.debug.assert).

I want to hook this into the v8 global error handler, but only after direct_v8
is merged.

Much of this is inspired by bun's code. They have their own assert (1) and
a [more sophisticated] crashHandler (2).
:

(1) beccd01647/src/bun.zig (L2987)
(2) beccd01647/src/crash_handler.zig (L198)
2026-01-19 07:36:46 +08:00
Karl Seguin
0edc1fcec7 fix rebase + migrate SubtleCrypto to new local 2026-01-19 07:36:14 +08:00
Karl Seguin
b46d3b22e2 Remove unnecessary handlescope
There's one _always_ created immediately before it.
2026-01-19 07:28:57 +08:00
Karl Seguin
412c881cd4 fix wpt and legacy_test runners 2026-01-19 07:28:56 +08:00
Karl Seguin
48f07a110f fix bad great rebase 2026-01-19 07:28:35 +08:00
Karl Seguin
5c1b7935e2 remove global handlescope 2026-01-19 07:28:35 +08:00
Karl Seguin
62aa564df1 Remove Global v8::Local<V8::Context>
When we create a js.Context, we create the underlying v8.Context and store it
for the duration of the page lifetime. This works because we have a global
HandleScope - the v8.Context (which is really a v8::Local<v8::Context>) is that
to the global HandleScope, effectively making it a global.

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

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

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

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

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

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

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

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

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

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

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

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

The bridge has been updated to support js.Function.Global for both input and
output parameters. Thus, window.setOnLoad can now directly take a
js.Function.Global, and window.getOnLoad can directly return that
js.Function.Global.
2026-01-19 07:26:33 +08:00
Karl Seguin
393227a786 Merge pull request #1373 from lightpanda-io/explicit_globals
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
Explicit globals
2026-01-19 07:26:04 +08:00
Karl Seguin
c5870353e3 update v8 dep 2026-01-19 07:17:45 +08:00
Karl Seguin
7c9941c629 Make Promise, PromiseResolver and Module have explicit globals.
See bb06900b6f84abaccc7ecfd386af1a9dc0029c50 for an explanation.
2026-01-19 07:15:48 +08:00
Karl Seguin
c7dbb6792d Make js.Object and js.Value have explicit global
See: bb06900b6f84abaccc7ecfd386af1a9dc0029c50 for details on this change.
2026-01-19 07:15:48 +08:00
Karl Seguin
728b2b7089 update v8 dep 2026-01-19 07:15:48 +08:00
Karl Seguin
5def997bed Make Global Function explicit.
This is the first in a series of changes to make globals explicit. The ultimate
goal of having explicit Globals is to move away from the global HandleScope and
to explicit HandleScopes.

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

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

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

The bridge has been updated to support js.Function.Global for both input and
output parameters. Thus, window.setOnLoad can now directly take a
js.Function.Global, and window.getOnLoad can directly return that
js.Function.Global.
2026-01-19 07:15:48 +08:00
Karl Seguin
a30c65966b Merge pull request #1380 from lightpanda-io/static_accessor_fix
Fix static accessors
2026-01-19 07:15:09 +08:00
Karl Seguin
cd67ed8a27 Fix static accessors
These are called without a self from v8, and should match that in Zig code.
2026-01-19 07:08:58 +08:00
Pierre Tachoire
5400dc783e Merge pull request #1379 from lightpanda-io/textarea_setDefaultValue
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 TextArea.defaultValue  setter
2026-01-18 17:12:49 +01:00
Pierre Tachoire
2880e9867d Merge pull request #1378 from lightpanda-io/performance_observer_use_after_free
Fix potential use-after-free with PerformanceObserver.
2026-01-18 17:03:36 +01:00
Karl Seguin
58f9469a6f Add TextArea.defaultValue setter 2026-01-18 07:49:58 +08:00
Karl Seguin
30d052db99 Fix potential use-after-free with PerformanceObserver.
TL;DR - use page.arena instead of page.call_arena

This probably comes from copying the implementation of MutationObserver and/or
IntersectionObserver. But those dispatches are different in that they directly
dispatch a slice (e.g. of MutationRecords) which gets mapped to a v8::Array when
doing the callback. The MutationRecords exist on the heap, not in
_pending_records, so the call_arena is fine.

PerformanceObserver returns an Zig object, not a slice. Therefore it gets mapped
to a v8::Object which references the Zig object. The state of that object, the
_entries list, has to exist for the lifetime of that object, not the call_arena.
2026-01-17 15:57:43 +08:00
Karl Seguin
744311f107 Merge pull request #1375 from lightpanda-io/nikneym/audio-constructor
Some checks failed
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-test / zig build release (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (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
Add `Audio` constructor
2026-01-16 23:25:56 +00:00
Karl Seguin
656674a477 Merge pull request #1356 from lightpanda-io/nikneym/subtle-crypto
Initial support for `SubtleCrypto` API
2026-01-16 23:21:35 +00:00
Karl Seguin
0e4aa38aaa Merge pull request #1312 from lightpanda-io/stagehand-zigdom
Stagehand zigdom
2026-01-16 23:10:19 +00:00
Pierre Tachoire
fdc267fa1f Merge pull request #1308 from lightpanda-io/axtree-backport
cdp: add AXTree
2026-01-16 17:56:40 +01:00
Pierre Tachoire
4325b80d64 axnode: small fixes 2026-01-16 17:30:43 +01:00
Pierre Tachoire
fbe07836f9 cdp: return a valide response for Page.getFrameTree on STARTUP
Stagehand expects a valid response for this specific command.
Add also `Target.activateTarget`
2026-01-16 16:27:55 +01:00
Halil Durak
304681bd21 add simplified setAttributeNS and getAttributeNS
This ignores namespaces for now, we have to come up with a solution if it becomes a necessity.
2026-01-16 18:13:43 +03:00
Halil Durak
05a01bb7c4 add Audio constructor 2026-01-16 17:38:54 +03:00
Pierre Tachoire
cbc028b040 cdp: accept multiple attachToTarget calls 2026-01-16 09:10:41 +01:00
Pierre Tachoire
2074c0149f axnode: add aria-labelledby support 2026-01-16 09:01:39 +01:00
Pierre Tachoire
61ed97dd45 axnode: use writeString for content's name 2026-01-16 09:00:57 +01:00
Pierre Tachoire
a358c46b9f axnode: ignore script and style children 2026-01-16 08:28:16 +01:00
Pierre Tachoire
50c1e2472b axnode: encode json string into stripWhitespaces 2026-01-16 08:27:43 +01:00
Halil Durak
ea2fc76d3c don't @panic! 2026-01-15 20:40:53 +03:00
Halil Durak
58634b54ec add tests for implemented bits of SubtleCrypto 2026-01-15 19:10:02 +03:00
Halil Durak
4b4bc1a4d3 don't allocate new SubtleCrypto for each access 2026-01-15 19:10:01 +03:00
Halil Durak
0549e07a90 implement deriveBits for X25519 2026-01-15 19:10:01 +03:00
Halil Durak
42666b1d30 add bindings needed for X25519 deriveBits implementation 2026-01-15 19:10:01 +03:00
Halil Durak
0a8be77233 create public/private key objects out of raw keys
This is needed for `deriveKey()` and `deriveBits()`.
2026-01-15 19:10:01 +03:00
Halil Durak
b26fb0e6c7 add more libcrypto bindings 2026-01-15 19:10:00 +03:00
Halil Durak
1699a92822 support x25519 init
Created a mess in previous commit.
2026-01-15 19:10:00 +03:00
Halil Durak
7ae3e8cb47 code cleanup, support keypairs, init support for X25519 2026-01-15 19:10:00 +03:00
Halil Durak
fd26ae4b5b parse keyUsages properly 2026-01-15 19:10:00 +03:00
Halil Durak
9945a5f9cc implement sign and verify for HMAC 2026-01-15 19:09:59 +03:00
Halil Durak
d5e9ae23ef ground zero SubtleCrypto 2026-01-15 19:09:59 +03:00
Pierre Tachoire
d50e056114 axnode: ignore non-html tags 2026-01-15 16:42:40 +01:00
Pierre Tachoire
d7d956d966 axnode: fix invalid enum 2026-01-15 15:40:52 +01:00
Pierre Tachoire
bd3966bf8d axnode: add focus on webroot 2026-01-15 15:37:49 +01:00
Pierre Tachoire
74578ba274 axnode: implement list marker 2026-01-15 15:37:49 +01:00
Pierre Tachoire
cb89742d2f axnode: add li level 2026-01-15 15:37:48 +01:00
Pierre Tachoire
6d0f991c17 axnode: add hr properties 2026-01-15 15:37:48 +01:00
Pierre Tachoire
d126d2a0f9 axnode: ignore hidden input 2026-01-15 15:37:47 +01:00
Pierre Tachoire
b51cca5617 axnode: use select.getValue 2026-01-15 15:37:47 +01:00
Pierre Tachoire
dc54dad290 axnode: add more attributes for input elements 2026-01-15 15:37:47 +01:00
Pierre Tachoire
7d6ab5a708 axnode: force manual formatting in switches
In order to uses less space and improve the readability.

zig fmt allows only 1 switch case per line or all in one line.
When having a lot of conditions, splitting the line is useful.
2026-01-15 15:37:46 +01:00
Pierre Tachoire
07acb9308d axnode: fallback button name to their tagname 2026-01-15 15:37:46 +01:00
Pierre Tachoire
ef315a46bc axnode: don't extract all text content as name
ignore name extraction for more elements
2026-01-15 15:37:45 +01:00
Pierre Tachoire
eb45bd051c axtree: simpler AXValue 2026-01-15 15:37:45 +01:00
Pierre Tachoire
65102edc98 axtree: remove useless error return 2026-01-15 15:37:44 +01:00
Pierre Tachoire
04eda96416 axtree: reverse writeNode return logic 2026-01-15 15:37:44 +01:00
Pierre Tachoire
f5036bdf5e axtree: use a simpler union switch 2026-01-15 15:37:44 +01:00
Pierre Tachoire
b6df85da7a axtree: add improvements 2026-01-15 15:37:43 +01:00
Pierre Tachoire
9775b39a8d axnode: use absolute urls 2026-01-15 15:37:43 +01:00
Pierre Tachoire
d6d74c5024 first version of AXTree 2026-01-15 15:37:42 +01:00
Pierre Tachoire
e09d15b12a add more generic HTML types 2026-01-15 15:37:35 +01:00
Karl Seguin
6d33d23935 Merge pull request #1371 from lightpanda-io/reject_non_new_constructor
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
Reject constructor calls without new
2026-01-15 12:06:55 +00:00
Karl Seguin
47760e00f7 Reject constructor calls without new
This was previously a fixed bug, but it got lost in the direct_v8 merging.

https://github.com/lightpanda-io/browser/pull/1316
2026-01-15 19:25:43 +08:00
Karl Seguin
72e8421099 Merge pull request #1366 from lightpanda-io/details_are_values
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
use js.Value when input can be a value
2026-01-14 23:25:17 +00:00
Karl Seguin
844b0ed457 Merge pull request #1368 from lightpanda-io/dupe_remove_id
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
Make removeIds lookup own the key
2026-01-14 11:52:06 +00:00
Karl Seguin
7e37db796f Make removeIds lookup own the key
Virtually all string are page-owned (in the page.arena), but because of the
Small String Optimization we use (string.zig), a string could be stack-allocated

The correct solution is probably to change the key to be a string.String. But
I want to give more thought to memory in general, and strings specifically need
to be thought about. So this is a quick fix for crashing.
2026-01-14 18:35:26 +08:00
Karl Seguin
3e5b506675 Merge pull request #1367 from lightpanda-io/readable_stream_cancel_persist
persist the readable stream's cancel callback
2026-01-14 10:20:14 +00:00
Karl Seguin
d356dbfc06 Merge pull request #1365 from lightpanda-io/try_catch_caught
Try catch caught
2026-01-14 09:59:19 +00:00
Karl Seguin
f5aee1f4c0 persist the readable stream's cancel callback 2026-01-14 17:58:41 +08:00
Karl Seguin
de4926d87d fix legacy runner, manual merge 2026-01-14 17:49:27 +08:00
Karl Seguin
56a39e2cc7 Apply tryCatch change to wpt runner 2026-01-14 17:34:08 +08:00
Karl Seguin
8e14dacc32 Improve ergonomics of try catch (and Function's tryCall)
It now returns a Caught struct which contains all information. The Caught struct
can be logged directly, providing more consistent logs for caught errors.
2026-01-14 17:34:02 +08:00
Karl Seguin
05102c673a use js.Value when input can be a value
We previously treated v8::Object and v8::Values interchangeably, and would just
ptrCast one to the other. So, if an API was defined with a js.Object but was
given a non-object value, e.g. 9001, it would still work.

This has since been tightened. If an API takes a js.Object, than the v8 value
must be an object. Passing a non-object will result in a InvalidArgument error.

CustomEvent.detail and PerformanceMark.detail can both be any value, so the
apis/fields have been updated from js.Object -> js.Value.
2026-01-14 15:38:34 +08:00
Karl Seguin
db2ecfe159 Merge pull request #1307 from lightpanda-io/direct_v8
Direct v8
2026-01-14 07:27:42 +00:00
Karl Seguin
640cb0d489 Merge pull request #1364 from lightpanda-io/observer_try_catch
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 trycatch to Intersection and Performance Observers
2026-01-14 02:10:56 +00:00
Karl Seguin
223a6170d5 Fix use-after free
On CDP.BrowserContext.deinit, clear the isolated world ExecutionContext before
terminating the session. This is important as the isolated_world list is
allocated from the session.arena.

Also, semi-revert 63f1c85964. Before all this
we were running microtasks on ExecutionWorld.removeContext. That didn't seem
right (and I thought it was the original source of the bug). But, for the "real"
Page context, this is critical, since Microtasks can reference the Page object.
Since microTasks are isolation-level, it's possible for a microtasks for Page1
to execute after Page1 goes away (if we create a new page, Page2). This re-adds
the microtask "draining", but only for the Page (i.e. in Page.deinit).
2026-01-14 09:37:10 +08:00
Karl Seguin
63f1c85964 Remove unnecessary microtask run.
This crashes linux in releasesafe without an embedded snapshot. Not sure why,
but it shouldn't be necessary. This was added back when we were executing
microtasks on a schedule, rather than manually at explicit points.
2026-01-13 18:09:58 +08:00
Karl Seguin
c252c8e870 update v8 dep version 2026-01-13 16:12:28 +08:00
Karl Seguin
801c019150 update v8 2026-01-13 16:07:49 +08:00
Karl Seguin
d77a6620f3 merge main 2026-01-13 13:05:16 +08:00
Karl Seguin
4e4a615df8 Move Env's FunctionTemplate from Global -> Eternal
(we'll move more to Eternal's, this is just a first teaser)
2026-01-13 12:58:31 +08:00
Karl Seguin
1b0ea44519 merge main 2026-01-13 12:58:31 +08:00
Karl Seguin
86f4ea108d Store snapshot templates in isolate, not context.
This lets us load the isolate without having to create a temp/dummy context
just to get the templates.

Call ContextDisposedNotification when a context is removed. Supposedly this can
help/hint to the isolate about memory management.
2026-01-13 12:58:30 +08:00
Karl Seguin
2322cb9b83 remove unused code, remove references to v8::Persistent 2026-01-13 12:58:30 +08:00
Karl Seguin
4720268426 Don't dupe StartupData, use what v8 gives us directly. 2026-01-13 12:58:30 +08:00
Karl Seguin
b4f134bff6 Prefer js.Value over js.Object in History/Navigation
Persist function callback in PerformanceObserver
2026-01-13 12:58:30 +08:00
Karl Seguin
f2a9125b99 js.v8 is not equal to js.v8.c
This means the C funtions/types now sit in the root of v8.
2026-01-13 12:58:30 +08:00
Karl Seguin
8438b7d561 remove remaining direct v8 references 2026-01-13 12:58:30 +08:00
Karl Seguin
18c846757b migrate almost all types 2026-01-13 12:58:28 +08:00
Karl Seguin
bc11a48e6b migrate most cases, merge Caller into bridge 2026-01-13 12:57:06 +08:00
Karl Seguin
01ecd725b8 cleanup resolvers 2026-01-13 12:57:06 +08:00
Karl Seguin
e6af7d1bd0 import more types 2026-01-13 12:57:06 +08:00
Karl Seguin
701de08e8a have our js.Context directly hold a js handle 2026-01-13 12:57:06 +08:00
Karl Seguin
363b95bdef Isolate and HandleScope 2026-01-13 12:57:06 +08:00
Karl Seguin
ca5a385b51 Port js.Object
Use js.Value in apis that should take values (not objects), like console.log
and setTimeout and reportError.
2026-01-13 12:57:03 +08:00
Karl Seguin
93f0d24673 port TryCatch 2026-01-13 12:56:07 +08:00
Karl Seguin
a5038893fe port Snapshot 2026-01-13 12:56:07 +08:00
Karl Seguin
3442f99a49 remove unused js.This 2026-01-13 12:56:07 +08:00
Karl Seguin
6ecf52cc03 port Platform and Inspector to use v8's C handles/functions directly 2026-01-13 12:56:07 +08:00
Karl Seguin
8aaef674fe Migrate Function and String 2026-01-13 12:56:07 +08:00
Karl Seguin
3b1cd06615 Make js.Array and js.Value directly contain their v8 handles. 2026-01-13 12:56:06 +08:00
Karl Seguin
4841f8cc8f add trycatch to Intersection and Performance Observers 2026-01-13 12:55:10 +08:00
Karl Seguin
d9d8f68bf8 Merge pull request #1361 from lightpanda-io/mutation-observer-trycall
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 tryCall for MutationObserver records callback
2026-01-12 22:42:33 +00:00
Pierre Tachoire
cf726d9813 fix double slash in import path 2026-01-12 17:59:49 +01:00
Pierre Tachoire
92be2c45d6 use tryCall for MutationObserver records callback
Instead of `call` to avoid uncaught error
2026-01-12 17:58:40 +01:00
Pierre Tachoire
914092b538 Merge pull request #1355 from lightpanda-io/console_apis
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 some missing APIs to Console
2026-01-12 15:15:24 +01:00
Pierre Tachoire
a8cd5fc266 Merge pull request #1354 from lightpanda-io/node_document
Node document
2026-01-12 15:14:57 +01:00
Pierre Tachoire
643f07fa10 Merge pull request #1352 from lightpanda-io/mutation_character_data
Mutation character data
2026-01-12 15:13:32 +01:00
Pierre Tachoire
0d77ff661b Merge pull request #1360 from lightpanda-io/wpt-v8
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
ci: fix wpt path
2026-01-12 10:53:51 +01:00
Pierre Tachoire
70d84b2f72 ci: fix wpt build path 2026-01-12 10:38:13 +01:00
Pierre Tachoire
41905ef735 Merge pull request #1358 from lightpanda-io/wpt-v8
ci: move fetch test from integration to e2e
2026-01-12 09:26:55 +01:00
Pierre Tachoire
2a468cc750 ci: split wpt build and run
vv8 build can pollute stdout output.
2026-01-12 09:18:16 +01:00
Pierre Tachoire
32520000c6 ci: use releaseFast mode for wpt 2026-01-12 09:09:36 +01:00
Pierre Tachoire
14db7a8eb3 ci: move fetch test from integration to e2e 2026-01-12 08:53:24 +01:00
Pierre Tachoire
8460e9a385 Merge pull request #1357 from lightpanda-io/wpt-v8
CI changes
2026-01-12 08:42:53 +01:00
Pierre Tachoire
933a93a703 ci: move fetch tests into 2e2 2026-01-12 08:32:52 +01:00
Pierre Tachoire
c2e09d3084 ci: build only run cmd forbuild dev test 2026-01-12 08:28:03 +01:00
Pierre Tachoire
98397401b8 ci: use compiled v8 with wpt tests 2026-01-12 08:11:55 +01:00
Karl Seguin
e042b1105a Merge pull request #1311 from lightpanda-io/nikneym/backport-canvas
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Backport dummy canvas APIs
2026-01-10 23:26:42 +00:00
Halil Durak
ee4775eb1a prefer underscore on fields 2026-01-10 14:13:41 +03:00
Halil Durak
6ff6232316 move isHexColor to color.zig 2026-01-10 14:13:09 +03:00
Karl Seguin
10035ab2f4 add some missing APIs to Console 2026-01-10 17:45:25 +08:00
Karl Seguin
2679175ae9 make createElement return DOMException on error 2026-01-10 16:00:11 +08:00
Karl Seguin
8d3aa1f3fa validate tag name given to document.createElement 2026-01-10 10:43:43 +08:00
Karl Seguin
75e78795ec Add Document.replaceChildren
Improve correctness (hierarchy validation) of various Document functions.
2026-01-10 10:32:02 +08:00
Karl Seguin
05f0f8901e make Node.isConnected() shadowroot-aware 2026-01-10 08:24:12 +08:00
Karl Seguin
6917aeb47b Walk document for doctype 2026-01-10 08:05:03 +08:00
Karl Seguin
516a86e33f Merge pull request #1331 from lightpanda-io/zigdom-selector-case-insensitive
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Adds case insensitivity to `Element.querySelector`
2026-01-09 23:20:39 +00:00
Halil Durak
7184a91c95 finalize canvas backport 2026-01-09 19:46:11 +03:00
Halil Durak
83e9d705cf backport dummy canvas APIs 2026-01-09 16:47:19 +03:00
Karl Seguin
bb907f5adb Support range mutation across nodes
Range mutation will trigger MutationObserver

MutationObserver with characterDataOldValue=true implicitly means
characterData=true

For MutationObserver-characterData test.
2026-01-09 20:42:23 +08:00
Karl Seguin
f1b60453bd Add getAttributeNamespace to MutationRecord 2026-01-09 20:25:49 +08:00
Pierre Tachoire
0ef339f12a Merge pull request #1349 from lightpanda-io/build-timeout
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
ci: increase timeout limit for build
2026-01-09 12:34:18 +01:00
Pierre Tachoire
5c0169ee05 ci: force release to be the latest 2026-01-09 11:44:38 +01:00
Pierre Tachoire
daf959ee90 Merge pull request #1347 from lightpanda-io/registerProtocolHandler
Add Navigator.registerProtocolHandler and unregisterProtocolHandler p…
2026-01-09 11:42:49 +01:00
Pierre Tachoire
89b43b6102 Merge pull request #1348 from lightpanda-io/wpt_events
Wpt events
2026-01-09 11:41:41 +01:00
Pierre Tachoire
d3b05201b9 Merge pull request #1350 from lightpanda-io/css_selector_escape_sequence
Support escape sequences in CSS selector for id and class selectors
2026-01-09 11:40:33 +01:00
Karl Seguin
127e53cf3a Merge pull request #1344 from lightpanda-io/indexed_fix
Define the index handler on the instance, not the prototype.
2026-01-09 10:38:54 +00:00
Pierre Tachoire
29281fe3ec Merge pull request #1346 from lightpanda-io/more_cloneNode
Support cloneNode for DocumentType and Attribute
2026-01-09 11:38:18 +01:00
Pierre Tachoire
a0fb55802f Merge pull request #1345 from lightpanda-io/add_more_explicit_types
Adds a number of HTML elements
2026-01-09 11:37:51 +01:00
Pierre Tachoire
90ec068367 Merge pull request #1351 from lightpanda-io/inspector-deinit-handlescope
use temporary handle scope to deinit inspector
2026-01-09 11:37:00 +01:00
Pierre Tachoire
f57cf1be75 use temporary handlescope to deinit inspector 2026-01-09 11:25:34 +01:00
Karl Seguin
3f44dee367 Support escape sequences in CSS selector for id and class selectors
Improves dom/nodes/ParentNode-querySelector-escapes.html from 20/68 -> 64/68.

Previous main had 66/68..but the last 4 are really edge cases that add a lot
of complexity.
2026-01-09 16:42:57 +08:00
Pierre Tachoire
82161ce94c ci: increase timeout limit for build 2026-01-09 09:14:01 +01:00
Karl Seguin
27b8e2a38c fix test 2026-01-09 14:38:47 +08:00
Karl Seguin
e5f2fbdcb2 clear isTrusted on redispatch and prevent redispatching while dispatching 2026-01-09 14:37:34 +08:00
Karl Seguin
cdf0cdd0ea Don't require handleEvent function to be on object event listener
When registering an object as an event listener, the handleEvent function
doesn't have to be defined then and there. The handleEvent function can be added
at any point in the future.
2026-01-09 14:27:00 +08:00
Karl Seguin
f12ff2c7bd Add Navigator.registerProtocolHandler and unregisterProtocolHandler placeholders 2026-01-09 13:40:19 +08:00
Karl Seguin
6c7c507d32 Support cloneNode for DocumentType and Attribute 2026-01-09 11:10:50 +08:00
Karl Seguin
0c97b8238b Adds a number of HTML elements
Instead of being mapped to HTMLUnknownElement, these will all be mapped to the
correct type. This is important for many WPT tests. But it's not impossible that
some script checks `if (x instanceof HTMLBaseElement)` and, without this, that
would error since HTMLBaseElement wouldn't be defined.
2026-01-09 10:56:23 +08:00
Karl Seguin
967a2030e6 Define the index handler on the instance, not the prototype.
While it sorta works if done on the prototype, it's incorrect as these are no
longer "own" properties (which some WPT tests care about). NamedIndexes were
already correctly defined on the instance.
2026-01-09 10:31:01 +08:00
Karl Seguin
78ebd5faf8 Merge pull request #1342 from lightpanda-io/better_namespace_support
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Improve support for non-HTML namespace
2026-01-09 07:05:36 +08:00
Karl Seguin
9d498fa069 Improve support for non-HTML namespace
This does a better job of tracking the implicit namespace based on the context.
For example, when using DOMParser.parseFromString with an XML namespace, all
subsequent elements will be in the XML namespace.

Adds support for null namespace.

Rather than defaulting to HTML, unknown namespaces now map to a special unknown
type. We don't currently preserve the original namespace, but we're at least
able to properly handle the casing in this case.
2026-01-09 07:03:52 +08:00
Karl Seguin
0db1ceaea7 Merge pull request #1339 from lightpanda-io/remove_className
Remove className function from every type
2026-01-09 06:57:40 +08:00
Karl Seguin
df27aeef6c Merge pull request #1343 from lightpanda-io/navigator-ua-suffix
return the app's user agent on Navigator.userAgent
2026-01-09 06:56:01 +08:00
Karl Seguin
5ae0df53bb Remove className function from every type
The toString symbol is now automatically implemented on any type with a
JsApi.Meta.Name, so className is no longer used.
2026-01-09 06:55:07 +08:00
Karl Seguin
48df6ae159 Merge pull request #1338 from lightpanda-io/HTMLSpanElement
add an explicit HTMLSpanElement
2026-01-09 06:52:17 +08:00
Pierre Tachoire
6cae2fcea7 return the app's user agent on Navigator.userAgent 2026-01-08 15:23:02 +01:00
Pierre Tachoire
d1d4d4894d Merge pull request #1341 from lightpanda-io/test-bench
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
tests: re-enable metrics JSON output
2026-01-08 14:01:56 +01:00
Pierre Tachoire
adfcf7bb2c tests: re-enable metrics JSON output
METRICS=true zig build test
2026-01-08 13:07:27 +01:00
Karl Seguin
c8f75cd266 Merge pull request #1340 from lightpanda-io/nikneym/parse-from-string-return
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Return `*Document` instead of tagged union in `parseFromString`
2026-01-08 19:16:14 +08:00
Halil Durak
282a9bbf65 return *Document instead of tagged union in parseFromString
Did a detour to XML PR and realized this is simpler.
2026-01-08 12:46:46 +03:00
Karl Seguin
d4c8af2a61 add an explicit HTMLSpanElement 2026-01-08 16:03:50 +08:00
Karl Seguin
060afcd459 Merge pull request #1313 from lightpanda-io/nikneym/xml-parsing
Support XML parsing
2026-01-08 08:42:21 +08:00
Karl Seguin
5d1522a61f Don't dispatch to listeners added during dispatching
Use the last current listener as a sentinel, so that any listener added during
dispatching can be skipped.
2026-01-08 08:39:49 +08:00
Karl Seguin
b1b54afc56 Merge pull request #1335 from lightpanda-io/build
Embed v8 snapshot in builds
2026-01-08 06:54:38 +08:00
Pierre Tachoire
2abc490732 ci: support build on tag push 2026-01-07 21:11:20 +01:00
Pierre Tachoire
d4807df2e9 add v8 snapshot instructions into the README 2026-01-07 17:22:50 +01:00
Pierre Tachoire
d5f4ca15cc add v8 snapshot in build processes 2026-01-07 17:22:49 +01:00
Pierre Tachoire
e642c85ebd use ReleaseFast build 2026-01-07 17:22:49 +01:00
Muki Kiboigo
3930524bbf use tokenizeAny instead of tokenizeScalar in Selector 2026-01-07 06:12:49 -08:00
Halil Durak
7ea0cdba36 update DomParser test 2026-01-07 14:37:44 +03:00
Halil Durak
612b3a26b7 allow other XML MIMEs in parseFromString 2026-01-07 14:37:44 +03:00
Halil Durak
56d89895a8 initial XML parsing support in DOMParser 2026-01-07 14:37:43 +03:00
Karl Seguin
21d502b81f Merge pull request #1326 from lightpanda-io/wp/mrdimidim/use-css-tokenizer
Use css tokenizer for parsing style attrs
2026-01-07 18:09:06 +08:00
Karl Seguin
dd3de6efea Merge pull request #1327 from lightpanda-io/zigdom-cdata-length
Fix `dom/nodes/CharacterData-appendData` WPT
2026-01-07 18:04:05 +08:00
Karl Seguin
d934fe6d4e Merge pull request #1330 from lightpanda-io/zigdom-element-get-by-class-name-fix
fix `dom/nodes/Element-getElementsByClassName` wpt
2026-01-07 18:02:50 +08:00
Karl Seguin
dab6345885 dispatch events with proper this 2026-01-07 17:57:34 +08:00
Karl Seguin
39874137d6 Merge pull request #1333 from lightpanda-io/attribute_removeNamedItem
add attribute.removeNamedItem
2026-01-07 17:47:33 +08:00
Karl Seguin
89f215c3ee Merge pull request #1332 from lightpanda-io/getElementById
getElementById duplicate-id handling
2026-01-07 17:47:19 +08:00
Karl Seguin
408d3f0a53 Track owning documents for nodes which aren't the default document
Track this in a lookup on the page, to avoid having to store a pointer for
_every_ node, given that most nodes _are_ owned by the document.

This helps us ensure nodes can be properly adopted.
2026-01-07 17:46:09 +08:00
Karl Seguin
a010684ce9 Add deprecated Node constants
Remove toString where the [new] auto-generated toString symbol works.

Reject node mutation on attributes.
2026-01-07 17:36:26 +08:00
Karl Seguin
a4a98da4a4 add attribute.removeNamedItem 2026-01-07 16:51:12 +08:00
Karl Seguin
6f30d459d5 getElementById duplicate-id handling
If 2 elements have the same id then,
1 - The first in document-order has to be retrieved. We were ordering by
    insertion order.

2 - When the element is removed, then document.getElementById should return the
    next element with that id in document-order. We were returning null
2026-01-07 15:49:17 +08:00
Muki Kiboigo
622ca3121f add case insensitivity support to selector parsing 2026-01-06 23:31:34 -08:00
Muki Kiboigo
71f27a55e1 fix duping of string for getElementsByClassName 2026-01-06 23:07:32 -08:00
Karl Seguin
c92903aae5 Merge pull request #1328 from lightpanda-io/set_outerHTML
add outerHTML setter
2026-01-07 15:04:14 +08:00
Karl Seguin
518e0aa07a add outerHTML setter 2026-01-07 14:49:30 +08:00
Nikolay Govorov
b908b0bf8a Used css tokenizer for parse html attributes 2026-01-07 05:46:18 +00:00
Muki Kiboigo
f9fa5be324 count utf8 codepoints for CData getLength 2026-01-06 21:29:15 -08:00
Karl Seguin
8ec6bb1577 Merge pull request #1322 from lightpanda-io/input_clone
Add 'clone' callback to build, implement for Input
2026-01-07 13:08:20 +08:00
Karl Seguin
70f8c53703 add deprecated properties to Event and improve initEvent 2026-01-07 12:05:11 +08:00
Karl Seguin
6d5a984413 Merge pull request #1323 from lightpanda-io/document_title
Improve document.title getter
2026-01-07 10:42:03 +08:00
Karl Seguin
5fa8fbc6f8 Merge pull request #1324 from lightpanda-io/elements_by_name_nodelist
getElementsByName now returns a NodeList rather than an HTMLCollection
2026-01-07 10:41:54 +08:00
Karl Seguin
7050d5fc68 Merge pull request #1325 from lightpanda-io/tokenlist_treewalker
support element.relList and improve TreeWalker
2026-01-07 10:41:42 +08:00
Karl Seguin
6af9d12f71 support element.relList and improve TreeWalker 2026-01-07 10:34:47 +08:00
Karl Seguin
a54e1db784 getElementsByName now returns a NodeList rather than an HTMLCollection
Auto-implement a toString accessor for any type that has a JsApi.Meta.name
2026-01-07 09:17:51 +08:00
Karl Seguin
2319b0fda5 Improve document.title getter
Collapse whitespace and find the first title, no matter where it is.
2026-01-07 07:52:20 +08:00
Karl Seguin
6864a22721 fix datetime-local input type 2026-01-07 07:39:42 +08:00
Karl Seguin
c9d0e2097d add input indeterminate accessor 2026-01-07 07:35:24 +08:00
Karl Seguin
d8f7eb3f24 Add 'clone' callback to build, implement for Input 2026-01-07 07:29:43 +08:00
Karl Seguin
90ee919f45 Merge pull request #1321 from lightpanda-io/event-init
set _time_stamp in the Event factory
2026-01-07 07:14:34 +08:00
Karl Seguin
ddc6431720 Merge pull request #1316 from lightpanda-io/reject_non_new_constructor
Reject constructors called as function (i.e. without 'new')
2026-01-07 07:14:13 +08:00
Pierre Tachoire
2ea6557fb7 add initEvent into Factory
and remove default value for Event._time_stamp
2026-01-06 15:30:13 +01:00
Karl Seguin
15358c1928 Improve Range, adding missing functions and more validation 2026-01-06 20:27:16 +08:00
Karl Seguin
d65025b3cb Merge pull request #1320 from lightpanda-io/fix-replaceChildren
remove children from previous parent
2026-01-06 19:07:15 +08:00
Pierre Tachoire
54fa3bc054 remove children from previous parent 2026-01-06 11:52:47 +01:00
Pierre Tachoire
68f5fa738c remove dead code Page._appendNode 2026-01-06 11:49:13 +01:00
Karl Seguin
2ea57ba979 update v8 dep 2026-01-06 18:30:25 +08:00
Karl Seguin
1acc0b0dc8 Merge pull request #1310 from lightpanda-io/zigdom-named-access
Named Access on the Window Object
2026-01-06 18:07:43 +08:00
Karl Seguin
645ec79fce access page from context, document call_depth usage 2026-01-06 18:04:17 +08:00
Karl Seguin
97e897e80e Merge pull request #1318 from lightpanda-io/node-self-replace
handle Node self replacement in insertBefore
2026-01-06 17:23:12 +08:00
Karl Seguin
6f72eeae65 Merge pull request #1319 from lightpanda-io/script_list_cleanup
Handle immediate call to Script.errorCallback
2026-01-06 17:22:14 +08:00
Karl Seguin
a845b2e35e Handle immediate call to Script.errorCallback
It's possible for Script.errorCallback to be called as part of the call to
`client.request`. This happens because we eagerly pump the libcurl message loop
to get the request going ASAP. For very obvious failures (e.g. an invalid URL)
this means that the error callback can be called from `client.request`.

Previously, we were only adding the script to its list _after_ the call to
`client.request`, but the error handler tries to remove the script from the list
.

This commit changes the order so that the script is first added to the list
and then the request is made.
2026-01-06 17:03:27 +08:00
Pierre Tachoire
b164ffeb95 handle Node self replacement in insertBefore 2026-01-06 09:49:08 +01:00
Karl Seguin
7ba34af884 Merge pull request #1317 from lightpanda-io/zigValueToJs-opts-pass-down
pass down opts to zigValueToJs
2026-01-06 16:36:44 +08:00
Pierre Tachoire
7f543ac7c8 pass down opts to zigValueToJs 2026-01-06 09:35:38 +01:00
Karl Seguin
a1bf92c07f Reject constructors called as function (i.e. without 'new')
Previously, `MessageEvent('')` would have been allowed, but invalid. This caused
problems as the receiver was the window. All such calls are now rejected.

Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/131
2026-01-06 16:03:37 +08:00
Karl Seguin
0b221615b7 Merge pull request #1315 from lightpanda-io/replaceChild-itself
fix Node.replaceChild when of new child equals old
2026-01-06 08:00:03 +08:00
Pierre Tachoire
f81a9b54a7 fix Node.replaceChild when of new child equals old 2026-01-05 21:48:59 +01:00
Muki Kiboigo
05da040ce1 increment call_depth on callWithThis 2026-01-05 09:27:17 -08:00
Muki Kiboigo
b911051842 add named access shadowing test 2026-01-05 09:08:57 -08:00
Muki Kiboigo
a67f46b550 add named access on the Window object 2026-01-05 08:41:42 -08:00
294 changed files with 25154 additions and 7901 deletions

View File

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

View File

@@ -5,8 +5,12 @@ env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.NIGHTLY_BUILD_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }}
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
on:
push:
tags:
- '*'
schedule:
- cron: "2 2 * * *"
@@ -23,7 +27,7 @@ jobs:
OS: linux
runs-on: ubuntu-22.04
timeout-minutes: 15
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
@@ -36,10 +40,12 @@ 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
- name: zig build
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseSafe -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -54,7 +60,8 @@ jobs:
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
tag: ${{ env.RELEASE }}
makeLatest: true
build-linux-aarch64:
env:
@@ -62,7 +69,7 @@ jobs:
OS: linux
runs-on: ubuntu-22.04-arm
timeout-minutes: 15
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
@@ -75,10 +82,12 @@ 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
- name: zig build
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseSafe -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -93,7 +102,8 @@ jobs:
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
tag: ${{ env.RELEASE }}
makeLatest: true
build-macos-aarch64:
env:
@@ -103,7 +113,7 @@ jobs:
# macos-14 runs on arm CPU. see
# https://github.com/actions/runner-images?tab=readme-ov-file
runs-on: macos-14
timeout-minutes: 15
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
@@ -116,10 +126,12 @@ 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
- name: zig build
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -134,7 +146,8 @@ jobs:
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
tag: ${{ env.RELEASE }}
makeLatest: true
build-macos-x86_64:
env:
@@ -142,7 +155,7 @@ jobs:
OS: macos
runs-on: macos-14-large
timeout-minutes: 15
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
@@ -155,10 +168,12 @@ 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
- name: zig build
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -173,4 +188,5 @@ jobs:
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
tag: ${{ env.RELEASE }}
makeLatest: true

View File

@@ -56,8 +56,6 @@ jobs:
submodules: recursive
- uses: ./.github/actions/install
with:
mode: 'release'
- name: zig build release
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
@@ -124,8 +122,8 @@ jobs:
needs: zig-build-release
env:
MAX_MEMORY: 28000
MAX_AVG_DURATION: 23
MAX_MEMORY: 26000
MAX_AVG_DURATION: 17
LIGHTPANDA_DISABLE_TELEMETRY: true
# use a self host runner.
@@ -232,3 +230,19 @@ jobs:
- name: format and send json result
run: /perf-fmt hyperfine ${{ github.sha }} hyperfine.json
browser-fetch:
name: browser fetch
needs: zig-build-release
runs-on: ubuntu-latest
steps:
- name: download artifact
uses: actions/download-artifact@v4
with:
name: lightpanda-build-release
- run: chmod a+x ./lightpanda
- run: ./lightpanda fetch https://demo-browser.lightpanda.io/campfire-commerce/

View File

@@ -30,8 +30,11 @@ jobs:
- uses: ./.github/actions/install
- name: json output
run: zig build wpt -- --json > wpt.json
- name: build wpt
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -- version
- name: run test with json output
run: zig-out/bin/lightpanda-wpt --json > wpt.json
- name: write commit
run: |

View File

@@ -12,8 +12,7 @@ on:
- main
paths:
- "build.zig"
- "src/**/*.zig"
- "src/*.zig"
- "src/**"
- "vendor/zig-js-runtime"
- ".github/**"
- "vendor/**"
@@ -38,51 +37,25 @@ on:
workflow_dispatch:
jobs:
zig-build-dev:
name: zig build dev
# Don't run the CI with draft PR.
if: github.event.pull_request.draft == false
zig-test-debug:
name: zig test using v8 in debug mode
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
- name: zig build debug
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a
- name: upload artifact
uses: actions/upload-artifact@v4
with:
name: lightpanda-build-dev
path: |
zig-out/bin/lightpanda
retention-days: 1
debug: true
browser-fetch:
name: browser fetch
needs: zig-build-dev
# Don't run the CI with draft PR.
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- name: download artifact
uses: actions/download-artifact@v4
with:
name: lightpanda-build-dev
- run: chmod a+x ./lightpanda
- run: ./lightpanda fetch https://httpbin.io/xhr/get
- name: zig build test
run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test
zig-test:
name: zig test
@@ -103,7 +76,7 @@ jobs:
- uses: ./.github/actions/install
- name: zig build test
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a test -- --json > bench.json
run: METRICS=true zig build -Dprebuilt_v8_path=v8/libc_v8.a test > bench.json
- name: write commit
run: |

View File

@@ -3,11 +3,12 @@ FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4
ARG ZIG_V8=v0.1.37
ARG ZIG_V8=v0.2.8
ARG TARGETPLATFORM
RUN apt-get update -yq && \
apt-get install -yq xz-utils ca-certificates \
pkg-config libglib2.0-dev \
clang make curl git
# Get Rust
@@ -48,8 +49,16 @@ RUN case $TARGETPLATFORM in \
mkdir -p v8/ && \
mv libc_v8.a v8/libc_v8.a
# build v8 snapshot
RUN zig build -Doptimize=ReleaseFast \
-Dprebuilt_v8_path=v8/libc_v8.a \
snapshot_creator -- src/snapshot.bin
# build release
RUN zig build -Doptimize=ReleaseFast -Dprebuilt_v8_path=v8/libc_v8.a -Dgit_commit=$(git rev-parse --short HEAD)
RUN zig build -Doptimize=ReleaseFast \
-Dsnapshot_path=../../snapshot.bin \
-Dprebuilt_v8_path=v8/libc_v8.a \
-Dgit_commit=$(git rev-parse --short HEAD)
FROM debian:stable-slim

View File

@@ -47,12 +47,18 @@ help:
# $(ZIG) commands
# ------------
.PHONY: build build-dev run run-release shell test bench wpt data end2end
.PHONY: build build-v8-snapshot build-dev run run-release shell test bench wpt data end2end
## Build in release-safe mode
build:
## Build v8 snapshot
build-v8-snapshot:
@printf "\033[36mBuilding v8 snapshot (release safe)...\033[0m\n"
@$(ZIG) build -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n"
## Build in release-fast mode
build: build-v8-snapshot
@printf "\033[36mBuilding (release safe)...\033[0m\n"
@$(ZIG) build -Doptimize=ReleaseSafe -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@$(ZIG) build -Doptimize=ReleaseFast -Dsnapshot_path=../../snapshot.bin -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n"
## Build in debug mode

View File

@@ -78,23 +78,49 @@ docker run -d --name lightpanda -p 9222:9222 lightpanda/browser:nightly
### Dump a URL
```console
./lightpanda fetch --dump https://lightpanda.io
./lightpanda fetch --obey_robots --log_format pretty --log_level info https://demo-browser.lightpanda.io/campfire-commerce/
```
```console
info(browser): GET https://lightpanda.io/ http.Status.ok
info(browser): fetch script https://api.website.lightpanda.io/js/script.js: http.Status.ok
info(browser): eval remote https://api.website.lightpanda.io/js/script.js: TypeError: Cannot read properties of undefined (reading 'pushState')
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
disabled = false
INFO page : navigate . . . . . . . . . . . . . . . . . . . . [+6ms]
url = https://demo-browser.lightpanda.io/campfire-commerce/
method = GET
reason = address_bar
body = false
req_id = 1
INFO browser : executing script . . . . . . . . . . . . . . [+118ms]
src = https://demo-browser.lightpanda.io/campfire-commerce/script.js
kind = javascript
cacheable = true
INFO http : request complete . . . . . . . . . . . . . . . . [+140ms]
source = xhr
url = https://demo-browser.lightpanda.io/campfire-commerce/json/product.json
status = 200
len = 4770
INFO http : request complete . . . . . . . . . . . . . . . . [+141ms]
source = fetch
url = https://demo-browser.lightpanda.io/campfire-commerce/json/reviews.json
status = 200
len = 1615
<!DOCTYPE html>
```
### Start a CDP server
```console
./lightpanda serve --host 127.0.0.1 --port 9222
./lightpanda serve --obey_robots --log_format pretty --log_level info --host 127.0.0.1 --port 9222
```
```console
info(websocket): starting blocking worker to listen on 127.0.0.1:9222
info(server): accepting new conn...
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
disabled = false
INFO app : server running . . . . . . . . . . . . . . . . . [+0ms]
address = 127.0.0.1:9222
```
Once the CDP server started, you can run a Puppeteer script by configuring the
@@ -115,7 +141,7 @@ const context = await browser.createBrowserContext();
const page = await context.newPage();
// Dump all the links from the page.
await page.goto('https://wikipedia.com/');
await page.goto('https://demo-browser.lightpanda.io/amiibo/', {waitUntil: "networkidle0"});
const links = await page.evaluate(() => {
return Array.from(document.querySelectorAll('a')).map(row => {
@@ -156,6 +182,7 @@ Here are the key features we have implemented:
- [x] Custom HTTP headers
- [x] Proxy support
- [x] Network interception
- [x] Respect `robots.txt` with option `--obey_robots`
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
@@ -178,6 +205,7 @@ For **Debian/Ubuntu based Linux**:
```
sudo apt install xz-utils ca-certificates \
pkg-config libglib2.0-dev \
clang make curl git
```
You also need to [install Rust](https://rust-lang.org/tools/install/).
@@ -211,6 +239,23 @@ env.
But you can directly use the zig command: `zig build run`.
#### Embed v8 snapshot
Lighpanda uses v8 snapshot. By default, it is created on startup but you can
embed it by using the following commands:
Generate the snapshot.
```
zig build snapshot_creator -- src/snapshot.bin
```
Build using the snapshot binary.
```
zig build -Dsnapshot_path=../../snapshot.bin
```
See [#1279](https://github.com/lightpanda-io/browser/pull/1279) for more details.
## Test
### Unit Tests

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: {
@@ -48,8 +49,9 @@ pub fn build(b: *Build) !void {
.sanitize_c = enable_csan,
.sanitize_thread = enable_tsan,
});
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;
};
@@ -117,7 +119,6 @@ pub fn build(b: *Build) !void {
}
{
// ZIGDOM
// browser
const exe = b.addExecutable(.{
.name = "legacy_test",
@@ -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/0d64a3d5b36ac94067df3e13fddbf715caa6f391.tar.gz",
.hash = "v8-0.0.0-xddH65sfBAC8o3q41YxhOms5uY2fvMzBrsgN8IeCXZgE",
.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,80 +21,53 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const log = @import("log.zig");
const Http = @import("http/Http.zig");
const Config = @import("Config.zig");
const Snapshot = @import("browser/js/Snapshot.zig");
const Platform = @import("browser/js/Platform.zig");
const Notification = @import("Notification.zig");
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
const RobotStore = @import("browser/Robots.zig").RobotStore;
pub const Http = @import("http/Http.zig");
pub const ArenaPool = @import("ArenaPool.zig");
// Container for global state / objects that various parts of the system
// might need.
const App = @This();
http: Http,
config: Config,
config: *const Config,
platform: Platform,
snapshot: Snapshot,
telemetry: Telemetry,
allocator: Allocator,
arena_pool: ArenaPool,
robots: RobotStore,
app_dir_path: ?[]const u8,
notification: *Notification,
shutdown: bool = false,
pub const RunMode = enum {
help,
fetch,
serve,
version,
};
pub const Config = struct {
run_mode: RunMode,
tls_verify_host: bool = true,
http_proxy: ?[:0]const u8 = null,
proxy_bearer_token: ?[:0]const u8 = null,
http_timeout_ms: ?u31 = null,
http_connect_timeout_ms: ?u31 = null,
http_max_host_open: ?u8 = null,
http_max_concurrent: ?u8 = null,
user_agent: [:0]const u8,
};
pub fn init(allocator: Allocator, config: Config) !*App {
pub fn init(allocator: Allocator, config: *const Config) !*App {
const app = try allocator.create(App);
errdefer allocator.destroy(app);
app.config = config;
app.allocator = allocator;
app.notification = try Notification.init(allocator, null);
errdefer app.notification.deinit();
app.robots = RobotStore.init(allocator);
app.http = try Http.init(allocator, .{
.max_host_open = config.http_max_host_open orelse 4,
.max_concurrent = config.http_max_concurrent orelse 10,
.timeout_ms = config.http_timeout_ms orelse 5000,
.connect_timeout_ms = config.http_connect_timeout_ms orelse 0,
.http_proxy = config.http_proxy,
.tls_verify_host = config.tls_verify_host,
.proxy_bearer_token = config.proxy_bearer_token,
.user_agent = config.user_agent,
});
app.http = try Http.init(allocator, &app.robots, config);
errdefer app.http.deinit();
app.platform = try Platform.init();
errdefer app.platform.deinit();
app.snapshot = try Snapshot.load(allocator);
errdefer app.snapshot.deinit(allocator);
app.snapshot = try Snapshot.load();
errdefer app.snapshot.deinit();
app.app_dir_path = getAndMakeAppDir(allocator);
app.telemetry = try Telemetry.init(app, config.run_mode);
app.telemetry = try Telemetry.init(app, config.mode);
errdefer app.telemetry.deinit();
try app.telemetry.register(app.notification);
app.arena_pool = ArenaPool.init(allocator);
errdefer app.arena_pool.deinit();
return app;
}
@@ -110,10 +83,11 @@ pub fn deinit(self: *App) void {
self.app_dir_path = null;
}
self.telemetry.deinit();
self.notification.deinit();
self.robots.deinit();
self.http.deinit();
self.snapshot.deinit(allocator);
self.snapshot.deinit();
self.platform.deinit();
self.arena_pool.deinit();
allocator.destroy(self);
}

87
src/ArenaPool.zig Normal file
View File

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

800
src/Config.zig Normal file
View File

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

View File

@@ -17,6 +17,7 @@
// 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("browser/Page.zig");
@@ -38,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
@@ -49,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.
@@ -65,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),
@@ -84,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) {
@@ -101,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);
@@ -161,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);
@@ -177,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;
}
@@ -241,7 +226,7 @@ pub fn unregister(self: *Notification, comptime event: EventType, receiver: anyt
if (listeners.items.len == 0) {
listeners.deinit(self.allocator);
const removed = self.listeners.remove(@intFromPtr(receiver));
std.debug.assert(removed == true);
lp.assert(removed == true, "Notification.unregister", .{ .type = event });
}
}
@@ -255,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;
@@ -312,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

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const net = std.net;
@@ -157,7 +158,7 @@ fn readLoop(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void {
});
defer http.removeCDPClient();
std.debug.assert(client.mode == .http);
lp.assert(client.mode == .http, "Server.readLoop invalid mode", .{});
while (true) {
if (http.poll(timeout_ms) != .cdp_socket) {
log.info(.app, "CDP timeout", .{});
@@ -204,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
}
}
}
@@ -236,7 +236,7 @@ pub const Client = struct {
const socket_flags = try posix.fcntl(socket, posix.F.GETFL, 0);
const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true }));
// we expect the socket to come to us as nonblocking
std.debug.assert(socket_flags & nonblocking == nonblocking);
lp.assert(socket_flags & nonblocking == nonblocking, "Client.init blocking", .{});
var reader = try Reader(true).init(server.allocator);
errdefer reader.deinit();
@@ -311,7 +311,7 @@ pub const Client = struct {
}
fn processHTTPRequest(self: *Client) !bool {
std.debug.assert(self.reader.pos == 0);
lp.assert(self.reader.pos == 0, "Client.HTTP pos", .{ .pos = self.reader.pos });
const request = self.reader.buf[0..self.reader.len];
if (request.len > MAX_HTTP_REQUEST_SIZE) {
@@ -560,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`.
@@ -592,8 +592,7 @@ pub const Client = struct {
// blocking and switch it back to non-blocking after the write
// is complete. Doesn't seem particularly efficiently, but
// this should virtually never happen.
std.debug.assert(changed_to_blocking == false);
log.debug(.app, "CDP write would block", .{});
lp.assert(changed_to_blocking == false, "Client.double block", .{});
changed_to_blocking = true;
_ = try posix.fcntl(self.socket, posix.F.SETFL, self.socket_flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true })));
continue :LOOP;
@@ -821,7 +820,7 @@ fn Reader(comptime EXPECT_MASK: bool) type {
const pos = self.pos;
const len = self.len;
std.debug.assert(pos <= len);
lp.assert(pos <= len, "Client.Reader.compact precondition", .{ .pos = pos, .len = len });
// how many (if any) partial bytes do we have
const partial_bytes = len - pos;
@@ -842,7 +841,7 @@ fn Reader(comptime EXPECT_MASK: bool) type {
const next_message_len = length_meta.@"1";
// if this isn't true, then we have a full message and it
// should have been processed.
std.debug.assert(next_message_len > partial_bytes);
lp.assert(pos <= len, "Client.Reader.compact postcondition", .{ .next_len = next_message_len, .partial = partial_bytes });
const missing_bytes = next_message_len - partial_bytes;
@@ -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:
@@ -929,7 +928,7 @@ fn fillWebsocketHeader(buf: std.ArrayListUnmanaged(u8)) []const u8 {
// makes the assumption that our caller reserved the first
// 10 bytes for the header
fn websocketHeader(buf: []u8, op_code: OpCode, payload_len: usize) []const u8 {
std.debug.assert(buf.len == 10);
lp.assert(buf.len == 10, "Websocket.Header", .{ .len = buf.len });
const len = payload_len;
buf[0] = 128 | @intFromEnum(op_code); // fin | opcode
@@ -1342,7 +1341,7 @@ fn assertWebSocketMessage(expected: []const u8, input: []const u8) !void {
}
const MockCDP = struct {
messages: std.ArrayListUnmanaged([]const u8) = .{},
messages: std.ArrayList([]const u8) = .{},
allocator: Allocator = testing.allocator,

View File

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

View File

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

View File

@@ -33,14 +33,38 @@ 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),
dispatch_depth: u32 = 0,
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 }),
pub fn init(page: *Page) EventManager {
return .{
@@ -50,6 +74,7 @@ pub fn init(page: *Page) EventManager {
.list_pool = std.heap.MemoryPool(std.DoublyLinkedList).init(page.arena),
.listener_pool = std.heap.MemoryPool(Listener).init(page.arena),
.dispatch_depth = 0,
.deferred_removals = .{},
};
}
@@ -67,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
@@ -77,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;
}
@@ -100,8 +129,8 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call
}
const func = switch (callback) {
.function => |f| Function{ .value = f },
.object => |o| Function{ .object = o },
.function => |f| Function{ .value = try f.persist() },
.object => |o| Function{ .object = try o.persist() },
};
const listener = try self.listener_pool.create();
@@ -112,23 +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 {
if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.remove", .{ .type = typ, .capture = use_capture, .target = target });
}
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 });
}
@@ -138,7 +178,10 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void
var was_handled = false;
defer if (was_handled) {
self.page.js.runMicrotasks();
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
};
switch (target._type) {
@@ -152,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);
},
}
@@ -181,12 +228,15 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
var was_dispatched = false;
defer if (was_dispatched) {
self.page.js.runMicrotasks();
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
};
if (function_) |func| {
event._current_target = target;
if (func.call(void, .{event})) {
if (func.callWithThis(void, target, .{event})) {
was_dispatched = true;
} else |err| {
// a non-JS error
@@ -194,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);
}
@@ -262,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;
@@ -273,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;
@@ -285,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;
@@ -297,40 +359,51 @@ 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 that we're dispatching to prevent immediate removal
// Track dispatch depth for deferred removal
self.dispatch_depth += 1;
defer {
self.dispatch_depth -= 1;
// Clean up any marked listeners in this target's list after this phase
// We do this regardless of depth to handle cross-target removals correctly
self.cleanupMarkedListeners(list);
const dispatch_depth = self.dispatch_depth;
// Only destroy deferred listeners when we exit the outermost dispatch
if (dispatch_depth == 1) {
for (self.deferred_removals.items) |removal| {
removal.list.remove(&removal.listener.node);
self.listener_pool.destroy(removal.listener);
}
self.deferred_removals.clearRetainingCapacity();
} else {
self.dispatch_depth = dispatch_depth - 1;
}
}
// Use the last listener in the list as sentinel - listeners added during dispatch will be after it
const last_node = list.last orelse return;
const last_listener: *Listener = @alignCast(@fieldParentPtr("node", last_node));
// Iterate through the list, stopping after we've encountered the last_listener
var node = list.first;
var is_done = false;
while (node) |n| {
// do this now, in case we need to remove n (once: true or aborted signal)
node = n.next;
if (is_done) {
break;
}
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
is_done = (listener == last_listener);
node = n.next;
// Skip listeners that were marked for removal
if (listener.marked_for_removal) {
continue;
}
if (!listener.typ.eql(typ)) {
continue;
}
// Can be null when dispatching to the target itself
// Skip non-matching listeners
if (comptime capture_only) |capture| {
if (listener.capture != capture) {
continue;
}
}
// Skip removed listeners
if (listener.removed) {
continue;
}
// If the listener has an aborted signal, remove it and skip
if (listener.signal) |signal| {
if (signal.getAborted()) {
@@ -339,6 +412,11 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
}
}
// Remove "once" listeners BEFORE calling them so nested dispatches don't see them
if (listener.once) {
self.removeListener(list, listener);
}
was_handled.* = true;
event._current_target = current_target;
@@ -348,13 +426,18 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
event._target = getAdjustedTarget(original_target, current_target);
}
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
switch (listener.function) {
.value => |value| try value.call(void, .{event}),
.value => |value| try ls.toLocal(value).callWithThis(void, current_target, .{event}),
.string => |string| {
const str = try page.call_arena.dupeZ(u8, string.str());
try self.page.js.eval(str, null);
try ls.local.eval(str, null);
},
.object => |obj| {
.object => |obj_global| {
const obj = ls.toLocal(obj_global);
if (try obj.getFunction("handleEvent")) |handleEvent| {
try handleEvent.callWithThis(void, obj, .{event});
}
@@ -366,10 +449,6 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
event._target = original_target;
}
if (listener.once) {
self.removeListener(list, listener);
}
if (event._stop_immediate_propagation) {
return;
}
@@ -382,30 +461,18 @@ fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target:
}
fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void {
// If we're in a dispatch, defer removal to avoid invalidating iteration
if (self.dispatch_depth > 0) {
// We're in the middle of dispatching, just mark for removal
// This prevents invalidating the linked list during iteration
listener.marked_for_removal = true;
listener.removed = true;
self.deferred_removals.append(self.arena, .{ .list = list, .listener = listener }) catch unreachable;
} else {
// Safe to remove immediately
// Outside dispatch, remove immediately
list.remove(&listener.node);
self.listener_pool.destroy(listener);
}
}
fn cleanupMarkedListeners(self: *EventManager, list: *std.DoublyLinkedList) void {
var node = list.first;
while (node) |n| {
node = n.next;
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
if (listener.marked_for_removal) {
list.remove(&listener.node);
self.listener_pool.destroy(listener);
}
}
}
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;
@@ -420,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;
@@ -436,24 +500,24 @@ const Listener = struct {
function: Function,
signal: ?*@import("webapi/AbortSignal.zig") = null,
node: std.DoublyLinkedList.Node,
marked_for_removal: bool = false,
removed: bool = false,
};
const Function = union(enum) {
value: js.Function,
value: js.Function.Global,
string: String,
object: js.Object,
object: js.Object.Global,
fn eqlFunction(self: Function, func: js.Function) bool {
return switch (self) {
.value => |v| return v.id == func.id,
.value => |v| v.isEqual(func),
else => false,
};
}
fn eqlObject(self: Function, obj: js.Object) bool {
return switch (self) {
.object => |o| return o.getId() == obj.getId(),
.object => |o| return o.isEqual(obj),
else => 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>
@@ -17,10 +17,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const assert = std.debug.assert;
const builtin = @import("builtin");
const reflect = @import("reflect.zig");
const IS_DEBUG = builtin.mode == .Debug;
const log = @import("../log.zig");
const String = @import("../string.zig").String;
@@ -31,6 +29,7 @@ const Page = @import("Page.zig");
const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig");
const UIEvent = @import("webapi/event/UIEvent.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig");
const Element = @import("webapi/Element.zig");
const Document = @import("webapi/Document.zig");
const EventTarget = @import("webapi/EventTarget.zig");
@@ -38,6 +37,11 @@ const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.
const Blob = @import("webapi/Blob.zig");
const AbstractRange = @import("webapi/AbstractRange.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
const assert = std.debug.assert;
const Factory = @This();
_page: *Page,
_slab: SlabAllocator,
@@ -169,43 +173,68 @@ pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
}
// 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.* = .{
._type = unionInit(Event.Type, chain.get(1)),
._type_string = try String.init(self._page.arena, typ, .{}),
};
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.* = .{
._type = unionInit(Event.Type, chain.get(1)),
._type_string = try String.init(self._page.arena, typ, .{}),
};
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
chain.setLeaf(2, child);
return chain.get(2);
}
pub fn mouseEvent(self: *Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, MouseEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
// Set MouseEvent with all its fields
const mouse_ptr = chain.get(2);
mouse_ptr.* = mouse;
mouse_ptr._proto = chain.get(1);
mouse_ptr._type = unionInit(MouseEvent.Type, chain.get(3));
chain.setLeaf(3, child);
return chain.get(3);
}
fn eventInit(self: *const Factory, arena: Allocator, typ: String, value: anytype) !Event {
// Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2;
return .{
._arena = arena,
._page = self._page,
._type = unionInit(Event.Type, value),
._type_string = typ,
._time_stamp = time_stamp,
};
}
pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
@@ -313,9 +342,7 @@ pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeO
return chain.get(4);
}
pub fn xhrEventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
pub fn xhrEventTarget(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) {
return try AutoPrototypeChain(
&.{ EventTarget, XMLHttpRequestEventTarget, @TypeOf(child) },
).create(allocator, child);
@@ -330,32 +357,6 @@ pub fn textTrackCue(self: *Factory, child: anytype) !*@TypeOf(child) {
).create(allocator, child);
}
fn hasChainRoot(comptime T: type) bool {
// Check if this is a root
if (@hasDecl(T, "_prototype_root")) {
return true;
}
// If no _proto field, we're at the top but not a recognized root
if (!@hasField(T, "_proto")) return false;
// Get the _proto field's type and recurse
const fields = @typeInfo(T).@"struct".fields;
inline for (fields) |field| {
if (std.mem.eql(u8, field.name, "_proto")) {
const ProtoType = reflect.Struct(field.type);
return hasChainRoot(ProtoType);
}
}
return false;
}
fn isChainType(comptime T: type) bool {
if (@hasField(T, "_proto")) return false;
return comptime hasChainRoot(T);
}
pub fn destroy(self: *Factory, value: anytype) void {
const S = reflect.Struct(@TypeOf(value));
@@ -372,7 +373,7 @@ pub fn destroy(self: *Factory, value: anytype) void {
}
}
if (comptime isChainType(S)) {
if (comptime @hasField(S, "_proto")) {
self.destroyChain(value, true, 0, std.mem.Alignment.@"1");
} else {
self.destroyStandalone(value);
@@ -380,20 +381,7 @@ pub fn destroy(self: *Factory, value: anytype) void {
}
pub fn destroyStandalone(self: *Factory, value: anytype) void {
const S = reflect.Struct(@TypeOf(value));
assert(!@hasDecl(S, "_prototype_root"));
const allocator = self._slab.allocator();
if (@hasDecl(S, "deinit")) {
// And it has a deinit, we'll call it
switch (@typeInfo(@TypeOf(S.deinit)).@"fn".params.len) {
1 => value.deinit(),
2 => value.deinit(self._page),
else => @compileLog(@typeName(S) ++ " has an invalid deinit function"),
}
}
allocator.destroy(value);
}
@@ -409,10 +397,8 @@ fn destroyChain(
// aligns the old size to the alignment of this element
const current_size = std.mem.alignForward(usize, old_size, @alignOf(S));
const alignment = std.mem.Alignment.fromByteUnits(@alignOf(S));
const new_align = std.mem.Alignment.max(old_align, alignment);
const new_size = current_size + @sizeOf(S);
const new_align = std.mem.Alignment.max(old_align, std.mem.Alignment.of(S));
// This is initially called from a deinit. We don't want to call that
// same deinit. So when this is the first time destroyChain is called
@@ -431,20 +417,15 @@ fn destroyChain(
if (@hasField(S, "_proto")) {
self.destroyChain(value._proto, false, new_size, new_align);
} else if (@hasDecl(S, "JsApi")) {
// Doesn't have a _proto, but has a JsApi.
if (self._page.js.removeTaggedMapping(@intFromPtr(value))) |tagged| {
allocator.destroy(tagged);
}
} else {
// no proto so this is the head of the chain.
// we use this as the ptr to the start of the chain.
// and we have summed up the length.
assert(@hasDecl(S, "_prototype_root"));
const memory_ptr: [*]const u8 = @ptrCast(value);
const memory_ptr: [*]u8 = @ptrCast(@constCast(value));
const len = std.mem.alignForward(usize, new_size, new_align.toByteUnits());
allocator.free(memory_ptr[0..len]);
allocator.rawFree(memory_ptr[0..len], new_align, @returnAddress());
}
}

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const js = @import("js/js.zig");
@@ -30,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;
@@ -137,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:
@@ -151,14 +158,14 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
script_element._executed = true;
const element = script_element.asElement();
if (element.getAttributeSafe("nomodule") != null) {
if (element.getAttributeSafe(comptime .wrap("nomodule")) != null) {
// these scripts should only be loaded if we don't support modules
// but since we do support modules, we can just skip them.
return;
}
const kind: Script.Kind = blk: {
const script_type = element.getAttributeSafe("type") orelse break :blk .javascript;
const script_type = element.getAttributeSafe(comptime .wrap("type")) orelse break :blk .javascript;
if (script_type.len == 0) {
break :blk .javascript;
}
@@ -185,7 +192,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
var source: Script.Source = undefined;
var remote_url: ?[:0]const u8 = null;
const base_url = page.base();
if (element.getAttributeSafe("src")) |src| {
if (element.getAttributeSafe(comptime .wrap("src"))) |src| {
if (try parseDataURI(page.arena, src)) |data_uri| {
source = .{ .@"inline" = data_uri };
} else {
@@ -216,12 +223,12 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
break :blk if (kind == .module) .@"defer" else .normal;
}
if (element.getAttributeSafe("async") != null) {
if (element.getAttributeSafe(comptime .wrap("async")) != null) {
break :blk .async;
}
// Check for defer or module (before checking dynamic script default)
if (kind == .module or element.getAttributeSafe("defer") != null) {
if (kind == .module or element.getAttributeSafe(comptime .wrap("defer")) != null) {
break :blk .@"defer";
}
@@ -239,20 +246,27 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
};
const is_blocking = script.mode == .normal;
if (remote_url) |url| {
errdefer script.deinit(true);
if (is_blocking == false) {
self.scriptList(script).append(&script.node);
}
var headers = try self.client.newHeaders();
try page.requestCookie(.{}).headersForRequest(page.arena, url, &headers);
if (remote_url) |url| {
errdefer {
if (is_blocking == false) {
self.scriptList(script).remove(&script.node);
}
script.deinit(true);
}
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,
@@ -261,18 +275,20 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
});
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.ctx = ctx,
.url = remote_url.?,
.element = element,
.stack = page.js.stackTrace() catch "???",
.stack = ls.local.stackTrace() catch "???",
});
}
}
if (is_blocking == false) {
const list = self.scriptList(script);
list.append(&script.node);
return;
}
@@ -345,15 +361,16 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.manager = self,
};
var headers = try self.client.newHeaders();
try self.page.requestCookie(.{}).headersForRequest(self.page.arena, url, &headers);
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.url = url,
.ctx = "module",
.referrer = referrer,
.stack = self.page.js.stackTrace() catch "???",
.stack = ls.local.stackTrace() catch "???",
});
}
@@ -361,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,
@@ -436,15 +454,16 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
} },
};
var headers = try self.client.newHeaders();
try self.page.requestCookie(.{}).headersForRequest(self.page.arena, url, &headers);
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.url = url,
.ctx = "dynamic module",
.referrer = referrer,
.stack = self.page.js.stackTrace() catch "???",
.stack = ls.local.stackTrace() catch "???",
});
}
@@ -460,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,
@@ -477,7 +497,7 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
// Called from the Page to let us know it's done parsing the HTML. Necessary that
// we know this so that we know that we can start evaluating deferred scripts.
pub fn staticScriptsDone(self: *ScriptManager) void {
std.debug.assert(self.static_scripts_done == false);
lp.assert(self.static_scripts_done == false, "ScriptManager.staticScriptsDone", .{});
self.static_scripts_done = true;
self.evaluate();
}
@@ -614,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) {
@@ -643,7 +663,7 @@ pub const Script = struct {
log.debug(.http, "script fetch start", .{ .req = transfer });
}
fn headerCallback(transfer: *Http.Transfer) !void {
fn headerCallback(transfer: *Http.Transfer) !bool {
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
const header = &transfer.response_header.?;
self.status = header.status;
@@ -653,7 +673,7 @@ pub const Script = struct {
.status = header.status,
.content_type = header.contentType(),
});
return;
return false;
}
if (comptime IS_DEBUG) {
@@ -664,16 +684,13 @@ pub const Script = struct {
});
}
// If this isn't true, then we'll likely leak memory. If you don't
// set `CURLOPT_SUPPRESS_CONNECT_HEADERS` and CONNECT to a proxy, this
// will fail. This assertion exists to catch incorrect assumptions about
// how libcurl works, or about how we've configured it.
std.debug.assert(self.source.remote.capacity == 0);
lp.assert(self.source.remote.capacity == 0, "ScriptManager.HeaderCallback", .{ .capacity = self.source.remote.capacity });
var buffer = self.manager.buffer_pool.get();
if (transfer.getContentLength()) |cl| {
try buffer.ensureTotalCapacity(self.manager.allocator, cl);
}
self.source = .{ .remote = buffer };
return true;
}
fn dataCallback(transfer: *Http.Transfer, data: []const u8) !void {
@@ -713,7 +730,7 @@ pub const Script = struct {
log.warn(.http, "script fetch error", .{
.err = err,
.req = self.url,
.mode = self.mode,
.mode = std.meta.activeTag(self.mode),
.kind = self.kind,
.status = self.status,
});
@@ -733,9 +750,13 @@ pub const Script = struct {
return;
}
if (self.mode == .import) {
const entry = self.manager.imported_modules.getPtr(self.url).?;
entry.state = .err;
switch (self.mode) {
.import_async => |ia| ia.callback(ia.data, error.FailedToLoad),
.import => {
const entry = manager.imported_modules.getPtr(self.url).?;
entry.state = .err;
},
else => {},
}
self.deinit(true);
manager.evaluate();
@@ -743,10 +764,12 @@ pub const Script = struct {
fn eval(self: *Script, page: *Page) void {
// never evaluated, source is passed back to v8, via callbacks.
std.debug.assert(self.mode != .import_async);
if (comptime IS_DEBUG) {
std.debug.assert(self.mode != .import_async);
// never evaluated, source is passed back to v8 when asked for it.
std.debug.assert(self.mode != .import);
// never evaluated, source is passed back to v8 when asked for it.
std.debug.assert(self.mode != .import);
}
if (page.isGoingAway()) {
// don't evaluate scripts for a dying page.
@@ -775,6 +798,12 @@ pub const Script = struct {
.cacheable = cacheable,
});
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
const local = &ls.local;
// Handle importmap special case here: the content is a JSON containing
// imports.
if (self.kind == .importmap) {
@@ -785,25 +814,24 @@ pub const Script = struct {
.kind = self.kind,
.cacheable = cacheable,
});
self.executeCallback("error", script_element._on_error, page);
self.executeCallback("error", local.toLocal(script_element._on_error), page);
return;
};
self.executeCallback("load", script_element._on_load, page);
self.executeCallback("load", local.toLocal(script_element._on_load), page);
return;
}
const js_context = page.js;
var try_catch: js.TryCatch = undefined;
try_catch.init(js_context);
try_catch.init(local);
defer try_catch.deinit();
const success = blk: {
const content = self.source.content();
switch (self.kind) {
.javascript => _ = js_context.eval(content, url) catch break :blk false,
.javascript => _ = local.eval(content, url) catch break :blk false,
.module => {
// We don't care about waiting for the evaluation here.
js_context.module(false, content, url, cacheable) catch break :blk false;
page.js.module(false, local, content, url, cacheable) catch break :blk false;
},
.importmap => unreachable, // handled before the try/catch.
}
@@ -816,34 +844,32 @@ pub const Script = struct {
defer {
// We should run microtasks even if script execution fails.
page.js.runMicrotasks();
_ = page.scheduler.run() catch |err| {
local.runMicrotasks();
_ = page.js.scheduler.run() catch |err| {
log.err(.page, "scheduler", .{ .err = err });
};
}
if (success) {
self.executeCallback("load", script_element._on_load, page);
self.executeCallback("load", local.toLocal(script_element._on_load), page);
return;
}
const msg = try_catch.err(page.arena) catch |err| @errorName(err) orelse "unknown";
const caught = try_catch.caughtOrError(page.call_arena, error.Unknown);
log.warn(.js, "eval script", .{
.url = url,
.err = msg,
.stack = try_catch.stack(page.call_arena) catch null,
.line = try_catch.sourceLineNumber() orelse 0,
.caught = caught,
.cacheable = cacheable,
});
self.executeCallback("error", script_element._on_error, page);
self.executeCallback("error", local.toLocal(script_element._on_error), page);
}
fn executeCallback(self: *const Script, comptime typ: []const u8, cb_: ?js.Function, page: *Page) void {
const cb = cb_ orelse return;
const Event = @import("webapi/Event.zig");
const event = Event.initTrusted(typ, .{}, page) catch |err| {
const event = Event.initTrusted(comptime .wrap(typ), .{}, page) catch |err| {
log.warn(.js, "script internal callback", .{
.url = self.url,
.type = typ,
@@ -851,14 +877,14 @@ pub const Script = struct {
});
return;
};
defer if (!event._v8_handoff) event.deinit(false);
var result: js.Function.Result = undefined;
cb.tryCall(void, .{event}, &result) catch {
var caught: js.TryCatch.Caught = undefined;
cb.tryCall(void, .{event}, &caught) catch {
log.warn(.js, "script callback", .{
.url = self.url,
.type = typ,
.err = result.exception,
.stack = result.stack,
.caught = caught,
});
};
}
@@ -875,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 {
@@ -900,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 .{};
@@ -912,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

@@ -17,6 +17,7 @@
// 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");
@@ -27,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;
@@ -38,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,
@@ -52,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(),
};
}
@@ -86,19 +86,18 @@ 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,
// the pointer on Page is just returned as a convenience
pub fn createPage(self: *Session) !*Page {
std.debug.assert(self.page == null);
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);
@@ -108,16 +107,15 @@ 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, .{});
std.debug.assert(self.page != null);
self.notification.dispatch(.page_remove, .{});
lp.assert(self.page != null, "Session.removePage - page is null", .{});
self.page.?.deinit();
self.page = null;
@@ -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>
@@ -76,8 +76,9 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
}
// trailing space so that we always have space to append the null terminator
var out = try std.mem.join(allocator, "", &.{ normalized_base, "/", path, " " });
const end = out.len - 1;
// and so that we can compare the next two characters without needing to length check
var out = try std.mem.join(allocator, "", &.{ normalized_base, "/", path, " " });
const end = out.len - 2;
const path_marker = path_start + 1;
@@ -87,33 +88,39 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
var in_i: usize = 0;
var out_i: usize = 0;
while (in_i < end) {
if (std.mem.startsWith(u8, out[in_i..], "./")) {
in_i += 2;
continue;
}
if (std.mem.startsWith(u8, out[in_i..], "../")) {
std.debug.assert(out[out_i - 1] == '/');
if (out_i > path_marker) {
// go back before the /
out_i -= 2;
while (out_i > 1 and out[out_i - 1] != '/') {
out_i -= 1;
}
} else {
// if out_i == path_marker, than we've reached the start of
// the path. We can't ../ any more. E.g.:
// http://www.example.com/../hello.
// You might think that's an error, but, at least with
// new URL('../hello', 'http://www.example.com/')
// it just ignores the extra ../
if (out[in_i] == '.' and (out_i == 0 or out[out_i - 1] == '/')) {
if (out[in_i + 1] == '/') { // always safe, because we added a whitespace
// /./
in_i += 2;
continue;
}
if (out[in_i + 1] == '.' and out[in_i + 2] == '/') { // always safe, because we added two whitespaces
// /../
if (out_i > path_marker) {
// go back before the /
out_i -= 2;
while (out_i > 1 and out[out_i - 1] != '/') {
out_i -= 1;
}
} else {
// if out_i == path_marker, than we've reached the start of
// the path. We can't ../ any more. E.g.:
// http://www.example.com/../hello.
// You might think that's an error, but, at least with
// new URL('../hello', 'http://www.example.com/')
// it just ignores the extra ../
}
in_i += 3;
continue;
}
if (in_i == end - 1) {
// ignore trailing dot
break;
}
in_i += 3;
continue;
}
out[out_i] = out[in_i];
const c = out[in_i];
out[out_i] = c;
in_i += 1;
out_i += 1;
}
@@ -495,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"));
@@ -541,6 +558,21 @@ test "URL: resolve" {
};
const cases = [_]Case{
.{
.base = "https://example/dir",
.path = "abc../test",
.expected = "https://example/abc../test",
},
.{
.base = "https://example/dir",
.path = "abc.",
.expected = "https://example/abc.",
},
.{
.base = "https://example/dir",
.path = "abc/.",
.expected = "https://example/abc/",
},
.{
.base = "https://example/xyz/abc/123",
.path = "something.js",
@@ -756,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);
}
}

298
src/browser/color.zig Normal file
View File

@@ -0,0 +1,298 @@
// 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 Io = std.Io;
pub fn isHexColor(value: []const u8) bool {
if (value.len == 0) {
return false;
}
if (value[0] != '#') {
return false;
}
const hex_part = value[1..];
switch (hex_part.len) {
3, 4, 6, 8 => for (hex_part) |c| if (!std.ascii.isHex(c)) return false,
else => return false,
}
return true;
}
pub const RGBA = packed struct(u32) {
r: u8,
g: u8,
b: u8,
/// Opaque by default.
a: u8 = std.math.maxInt(u8),
pub const Named = struct {
// Basic colors (CSS Level 1)
pub const black: RGBA = .init(0, 0, 0, 1);
pub const silver: RGBA = .init(192, 192, 192, 1);
pub const gray: RGBA = .init(128, 128, 128, 1);
pub const white: RGBA = .init(255, 255, 255, 1);
pub const maroon: RGBA = .init(128, 0, 0, 1);
pub const red: RGBA = .init(255, 0, 0, 1);
pub const purple: RGBA = .init(128, 0, 128, 1);
pub const fuchsia: RGBA = .init(255, 0, 255, 1);
pub const green: RGBA = .init(0, 128, 0, 1);
pub const lime: RGBA = .init(0, 255, 0, 1);
pub const olive: RGBA = .init(128, 128, 0, 1);
pub const yellow: RGBA = .init(255, 255, 0, 1);
pub const navy: RGBA = .init(0, 0, 128, 1);
pub const blue: RGBA = .init(0, 0, 255, 1);
pub const teal: RGBA = .init(0, 128, 128, 1);
pub const aqua: RGBA = .init(0, 255, 255, 1);
// Extended colors (CSS Level 2+)
pub const aliceblue: RGBA = .init(240, 248, 255, 1);
pub const antiquewhite: RGBA = .init(250, 235, 215, 1);
pub const aquamarine: RGBA = .init(127, 255, 212, 1);
pub const azure: RGBA = .init(240, 255, 255, 1);
pub const beige: RGBA = .init(245, 245, 220, 1);
pub const bisque: RGBA = .init(255, 228, 196, 1);
pub const blanchedalmond: RGBA = .init(255, 235, 205, 1);
pub const blueviolet: RGBA = .init(138, 43, 226, 1);
pub const brown: RGBA = .init(165, 42, 42, 1);
pub const burlywood: RGBA = .init(222, 184, 135, 1);
pub const cadetblue: RGBA = .init(95, 158, 160, 1);
pub const chartreuse: RGBA = .init(127, 255, 0, 1);
pub const chocolate: RGBA = .init(210, 105, 30, 1);
pub const coral: RGBA = .init(255, 127, 80, 1);
pub const cornflowerblue: RGBA = .init(100, 149, 237, 1);
pub const cornsilk: RGBA = .init(255, 248, 220, 1);
pub const crimson: RGBA = .init(220, 20, 60, 1);
pub const cyan: RGBA = .init(0, 255, 255, 1); // Synonym of aqua
pub const darkblue: RGBA = .init(0, 0, 139, 1);
pub const darkcyan: RGBA = .init(0, 139, 139, 1);
pub const darkgoldenrod: RGBA = .init(184, 134, 11, 1);
pub const darkgray: RGBA = .init(169, 169, 169, 1);
pub const darkgreen: RGBA = .init(0, 100, 0, 1);
pub const darkgrey: RGBA = .init(169, 169, 169, 1); // Synonym of darkgray
pub const darkkhaki: RGBA = .init(189, 183, 107, 1);
pub const darkmagenta: RGBA = .init(139, 0, 139, 1);
pub const darkolivegreen: RGBA = .init(85, 107, 47, 1);
pub const darkorange: RGBA = .init(255, 140, 0, 1);
pub const darkorchid: RGBA = .init(153, 50, 204, 1);
pub const darkred: RGBA = .init(139, 0, 0, 1);
pub const darksalmon: RGBA = .init(233, 150, 122, 1);
pub const darkseagreen: RGBA = .init(143, 188, 143, 1);
pub const darkslateblue: RGBA = .init(72, 61, 139, 1);
pub const darkslategray: RGBA = .init(47, 79, 79, 1);
pub const darkslategrey: RGBA = .init(47, 79, 79, 1); // Synonym of darkslategray
pub const darkturquoise: RGBA = .init(0, 206, 209, 1);
pub const darkviolet: RGBA = .init(148, 0, 211, 1);
pub const deeppink: RGBA = .init(255, 20, 147, 1);
pub const deepskyblue: RGBA = .init(0, 191, 255, 1);
pub const dimgray: RGBA = .init(105, 105, 105, 1);
pub const dimgrey: RGBA = .init(105, 105, 105, 1); // Synonym of dimgray
pub const dodgerblue: RGBA = .init(30, 144, 255, 1);
pub const firebrick: RGBA = .init(178, 34, 34, 1);
pub const floralwhite: RGBA = .init(255, 250, 240, 1);
pub const forestgreen: RGBA = .init(34, 139, 34, 1);
pub const gainsboro: RGBA = .init(220, 220, 220, 1);
pub const ghostwhite: RGBA = .init(248, 248, 255, 1);
pub const gold: RGBA = .init(255, 215, 0, 1);
pub const goldenrod: RGBA = .init(218, 165, 32, 1);
pub const greenyellow: RGBA = .init(173, 255, 47, 1);
pub const grey: RGBA = .init(128, 128, 128, 1); // Synonym of gray
pub const honeydew: RGBA = .init(240, 255, 240, 1);
pub const hotpink: RGBA = .init(255, 105, 180, 1);
pub const indianred: RGBA = .init(205, 92, 92, 1);
pub const indigo: RGBA = .init(75, 0, 130, 1);
pub const ivory: RGBA = .init(255, 255, 240, 1);
pub const khaki: RGBA = .init(240, 230, 140, 1);
pub const lavender: RGBA = .init(230, 230, 250, 1);
pub const lavenderblush: RGBA = .init(255, 240, 245, 1);
pub const lawngreen: RGBA = .init(124, 252, 0, 1);
pub const lemonchiffon: RGBA = .init(255, 250, 205, 1);
pub const lightblue: RGBA = .init(173, 216, 230, 1);
pub const lightcoral: RGBA = .init(240, 128, 128, 1);
pub const lightcyan: RGBA = .init(224, 255, 255, 1);
pub const lightgoldenrodyellow: RGBA = .init(250, 250, 210, 1);
pub const lightgray: RGBA = .init(211, 211, 211, 1);
pub const lightgreen: RGBA = .init(144, 238, 144, 1);
pub const lightgrey: RGBA = .init(211, 211, 211, 1); // Synonym of lightgray
pub const lightpink: RGBA = .init(255, 182, 193, 1);
pub const lightsalmon: RGBA = .init(255, 160, 122, 1);
pub const lightseagreen: RGBA = .init(32, 178, 170, 1);
pub const lightskyblue: RGBA = .init(135, 206, 250, 1);
pub const lightslategray: RGBA = .init(119, 136, 153, 1);
pub const lightslategrey: RGBA = .init(119, 136, 153, 1); // Synonym of lightslategray
pub const lightsteelblue: RGBA = .init(176, 196, 222, 1);
pub const lightyellow: RGBA = .init(255, 255, 224, 1);
pub const limegreen: RGBA = .init(50, 205, 50, 1);
pub const linen: RGBA = .init(250, 240, 230, 1);
pub const magenta: RGBA = .init(255, 0, 255, 1); // Synonym of fuchsia
pub const mediumaquamarine: RGBA = .init(102, 205, 170, 1);
pub const mediumblue: RGBA = .init(0, 0, 205, 1);
pub const mediumorchid: RGBA = .init(186, 85, 211, 1);
pub const mediumpurple: RGBA = .init(147, 112, 219, 1);
pub const mediumseagreen: RGBA = .init(60, 179, 113, 1);
pub const mediumslateblue: RGBA = .init(123, 104, 238, 1);
pub const mediumspringgreen: RGBA = .init(0, 250, 154, 1);
pub const mediumturquoise: RGBA = .init(72, 209, 204, 1);
pub const mediumvioletred: RGBA = .init(199, 21, 133, 1);
pub const midnightblue: RGBA = .init(25, 25, 112, 1);
pub const mintcream: RGBA = .init(245, 255, 250, 1);
pub const mistyrose: RGBA = .init(255, 228, 225, 1);
pub const moccasin: RGBA = .init(255, 228, 181, 1);
pub const navajowhite: RGBA = .init(255, 222, 173, 1);
pub const oldlace: RGBA = .init(253, 245, 230, 1);
pub const olivedrab: RGBA = .init(107, 142, 35, 1);
pub const orange: RGBA = .init(255, 165, 0, 1);
pub const orangered: RGBA = .init(255, 69, 0, 1);
pub const orchid: RGBA = .init(218, 112, 214, 1);
pub const palegoldenrod: RGBA = .init(238, 232, 170, 1);
pub const palegreen: RGBA = .init(152, 251, 152, 1);
pub const paleturquoise: RGBA = .init(175, 238, 238, 1);
pub const palevioletred: RGBA = .init(219, 112, 147, 1);
pub const papayawhip: RGBA = .init(255, 239, 213, 1);
pub const peachpuff: RGBA = .init(255, 218, 185, 1);
pub const peru: RGBA = .init(205, 133, 63, 1);
pub const pink: RGBA = .init(255, 192, 203, 1);
pub const plum: RGBA = .init(221, 160, 221, 1);
pub const powderblue: RGBA = .init(176, 224, 230, 1);
pub const rebeccapurple: RGBA = .init(102, 51, 153, 1);
pub const rosybrown: RGBA = .init(188, 143, 143, 1);
pub const royalblue: RGBA = .init(65, 105, 225, 1);
pub const saddlebrown: RGBA = .init(139, 69, 19, 1);
pub const salmon: RGBA = .init(250, 128, 114, 1);
pub const sandybrown: RGBA = .init(244, 164, 96, 1);
pub const seagreen: RGBA = .init(46, 139, 87, 1);
pub const seashell: RGBA = .init(255, 245, 238, 1);
pub const sienna: RGBA = .init(160, 82, 45, 1);
pub const skyblue: RGBA = .init(135, 206, 235, 1);
pub const slateblue: RGBA = .init(106, 90, 205, 1);
pub const slategray: RGBA = .init(112, 128, 144, 1);
pub const slategrey: RGBA = .init(112, 128, 144, 1); // Synonym of slategray
pub const snow: RGBA = .init(255, 250, 250, 1);
pub const springgreen: RGBA = .init(0, 255, 127, 1);
pub const steelblue: RGBA = .init(70, 130, 180, 1);
pub const tan: RGBA = .init(210, 180, 140, 1);
pub const thistle: RGBA = .init(216, 191, 216, 1);
pub const tomato: RGBA = .init(255, 99, 71, 1);
pub const transparent: RGBA = .init(0, 0, 0, 0);
pub const turquoise: RGBA = .init(64, 224, 208, 1);
pub const violet: RGBA = .init(238, 130, 238, 1);
pub const wheat: RGBA = .init(245, 222, 179, 1);
pub const whitesmoke: RGBA = .init(245, 245, 245, 1);
pub const yellowgreen: RGBA = .init(154, 205, 50, 1);
};
pub fn init(r: u8, g: u8, b: u8, a: f32) RGBA {
const clamped = std.math.clamp(a, 0, 1);
return .{ .r = r, .g = g, .b = b, .a = @intFromFloat(clamped * 255) };
}
/// Finds a color by its name.
pub fn find(name: []const u8) ?RGBA {
const match = std.meta.stringToEnum(std.meta.DeclEnum(Named), name) orelse return null;
return switch (match) {
inline else => |comptime_enum| @field(Named, @tagName(comptime_enum)),
};
}
/// Parses the given color.
/// Currently we only parse hex colors and named colors; other variants
/// require CSS evaluation.
pub fn parse(input: []const u8) !RGBA {
if (!isHexColor(input)) {
// Try named colors.
return find(input) orelse return error.Invalid;
}
const slice = input[1..];
switch (slice.len) {
// This means the digit for a color is repeated.
// Given HEX is #f0c, its interpreted the same as #FF00CC.
3 => {
const r = try std.fmt.parseInt(u8, &.{ slice[0], slice[0] }, 16);
const g = try std.fmt.parseInt(u8, &.{ slice[1], slice[1] }, 16);
const b = try std.fmt.parseInt(u8, &.{ slice[2], slice[2] }, 16);
return .{ .r = r, .g = g, .b = b, .a = 255 };
},
4 => {
const r = try std.fmt.parseInt(u8, &.{ slice[0], slice[0] }, 16);
const g = try std.fmt.parseInt(u8, &.{ slice[1], slice[1] }, 16);
const b = try std.fmt.parseInt(u8, &.{ slice[2], slice[2] }, 16);
const a = try std.fmt.parseInt(u8, &.{ slice[3], slice[3] }, 16);
return .{ .r = r, .g = g, .b = b, .a = a };
},
// Regular HEX format.
6 => {
const r = try std.fmt.parseInt(u8, slice[0..2], 16);
const g = try std.fmt.parseInt(u8, slice[2..4], 16);
const b = try std.fmt.parseInt(u8, slice[4..6], 16);
return .{ .r = r, .g = g, .b = b, .a = 255 };
},
8 => {
const r = try std.fmt.parseInt(u8, slice[0..2], 16);
const g = try std.fmt.parseInt(u8, slice[2..4], 16);
const b = try std.fmt.parseInt(u8, slice[4..6], 16);
const a = try std.fmt.parseInt(u8, slice[6..8], 16);
return .{ .r = r, .g = g, .b = b, .a = a };
},
else => return error.Invalid,
}
}
/// By default, browsers prefer lowercase formatting.
const format_upper = false;
/// Formats the `Color` according to web expectations.
/// If color is opaque, HEX is preferred; RGBA otherwise.
pub fn format(self: *const RGBA, writer: *Io.Writer) Io.Writer.Error!void {
if (self.isOpaque()) {
// Convert RGB to HEX.
// https://gristle.tripod.com/hexconv.html
// Hexadecimal characters up to 15.
const char: []const u8 = "0123456789" ++ if (format_upper) "ABCDEF" else "abcdef";
// This variant always prefers 6 digit format, +1 is for hash char.
const buffer = [7]u8{
'#',
char[self.r >> 4],
char[self.r & 15],
char[self.g >> 4],
char[self.g & 15],
char[self.b >> 4],
char[self.b & 15],
};
return writer.writeAll(&buffer);
}
// Prefer RGBA format for everything else.
return writer.print("rgba({d}, {d}, {d}, {d:.2})", .{ self.r, self.g, self.b, self.normalizedAlpha() });
}
/// Returns true if `Color` is opaque.
pub inline fn isOpaque(self: *const RGBA) bool {
return self.a == std.math.maxInt(u8);
}
/// Returns the normalized alpha value.
pub inline fn normalizedAlpha(self: *const RGBA) f32 {
return @as(f32, @floatFromInt(self.a)) / 255;
}
};

295
src/browser/css/Parser.zig Normal file
View File

@@ -0,0 +1,295 @@
// 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 Tokenizer = @import("Tokenizer.zig");
pub const Declaration = struct {
name: []const u8,
value: []const u8,
important: bool,
};
const TokenSpan = struct {
token: Tokenizer.Token,
start: usize,
end: usize,
};
const TokenStream = struct {
tokenizer: Tokenizer,
peeked: ?TokenSpan = null,
fn init(input: []const u8) TokenStream {
return .{ .tokenizer = .{ .input = input } };
}
fn nextRaw(self: *TokenStream) ?TokenSpan {
const start = self.tokenizer.position;
const token = self.tokenizer.next() orelse return null;
const end = self.tokenizer.position;
return .{ .token = token, .start = start, .end = end };
}
fn next(self: *TokenStream) ?TokenSpan {
if (self.peeked) |token| {
self.peeked = null;
return token;
}
return self.nextRaw();
}
fn peek(self: *TokenStream) ?TokenSpan {
if (self.peeked == null) {
self.peeked = self.nextRaw();
}
return self.peeked;
}
};
pub fn parseDeclarationsList(input: []const u8) DeclarationsIterator {
return DeclarationsIterator.init(input);
}
pub const DeclarationsIterator = struct {
input: []const u8,
stream: TokenStream,
pub fn init(input: []const u8) DeclarationsIterator {
return .{
.input = input,
.stream = TokenStream.init(input),
};
}
pub fn next(self: *DeclarationsIterator) ?Declaration {
while (true) {
self.skipTriviaAndSemicolons();
const peeked = self.stream.peek() orelse return null;
switch (peeked.token) {
.at_keyword => {
_ = self.stream.next();
self.skipAtRule();
},
.ident => |name| {
_ = self.stream.next();
if (self.consumeDeclaration(name)) |declaration| {
return declaration;
}
},
else => {
_ = self.stream.next();
self.skipInvalidDeclaration();
},
}
}
return null;
}
fn consumeDeclaration(self: *DeclarationsIterator, name: []const u8) ?Declaration {
self.skipTrivia();
const colon = self.stream.next() orelse return null;
if (!isColon(colon.token)) {
self.skipInvalidDeclaration();
return null;
}
const value = self.consumeValue() orelse return null;
return .{
.name = name,
.value = value.value,
.important = value.important,
};
}
const ValueResult = struct {
value: []const u8,
important: bool,
};
fn consumeValue(self: *DeclarationsIterator) ?ValueResult {
self.skipTrivia();
var depth: usize = 0;
var start: ?usize = null;
var last_sig: ?TokenSpan = null;
var prev_sig: ?TokenSpan = null;
while (true) {
const peeked = self.stream.peek() orelse break;
if (isSemicolon(peeked.token) and depth == 0) {
_ = self.stream.next();
break;
}
const span = self.stream.next() orelse break;
if (isWhitespaceOrComment(span.token)) {
continue;
}
if (start == null) start = span.start;
prev_sig = last_sig;
last_sig = span;
updateDepth(span.token, &depth);
}
const value_start = start orelse return null;
const last = last_sig orelse return null;
var important = false;
var end_pos = last.end;
if (isImportantPair(prev_sig, last)) {
important = true;
const bang = prev_sig orelse return null;
if (value_start >= bang.start) return null;
end_pos = bang.start;
}
var value_slice = self.input[value_start..end_pos];
value_slice = std.mem.trim(u8, value_slice, &std.ascii.whitespace);
if (value_slice.len == 0) return null;
return .{ .value = value_slice, .important = important };
}
fn skipTrivia(self: *DeclarationsIterator) void {
while (self.stream.peek()) |peeked| {
if (!isWhitespaceOrComment(peeked.token)) break;
_ = self.stream.next();
}
}
fn skipTriviaAndSemicolons(self: *DeclarationsIterator) void {
while (self.stream.peek()) |peeked| {
if (isWhitespaceOrComment(peeked.token) or isSemicolon(peeked.token)) {
_ = self.stream.next();
} else {
break;
}
}
}
fn skipAtRule(self: *DeclarationsIterator) void {
var depth: usize = 0;
var saw_block = false;
while (true) {
const peeked = self.stream.peek() orelse return;
if (!saw_block and isSemicolon(peeked.token) and depth == 0) {
_ = self.stream.next();
return;
}
const span = self.stream.next() orelse return;
if (isWhitespaceOrComment(span.token)) continue;
if (isBlockStart(span.token)) {
depth += 1;
saw_block = true;
} else if (isBlockEnd(span.token)) {
if (depth > 0) depth -= 1;
if (saw_block and depth == 0) return;
}
}
}
fn skipInvalidDeclaration(self: *DeclarationsIterator) void {
var depth: usize = 0;
while (self.stream.peek()) |peeked| {
if (isSemicolon(peeked.token) and depth == 0) {
_ = self.stream.next();
return;
}
const span = self.stream.next() orelse return;
if (isWhitespaceOrComment(span.token)) continue;
updateDepth(span.token, &depth);
}
}
};
fn isWhitespaceOrComment(token: Tokenizer.Token) bool {
return switch (token) {
.white_space, .comment => true,
else => false,
};
}
fn isSemicolon(token: Tokenizer.Token) bool {
return switch (token) {
.semicolon => true,
else => false,
};
}
fn isColon(token: Tokenizer.Token) bool {
return switch (token) {
.colon => true,
else => false,
};
}
fn isBlockStart(token: Tokenizer.Token) bool {
return switch (token) {
.curly_bracket_block, .square_bracket_block, .parenthesis_block, .function => true,
else => false,
};
}
fn isBlockEnd(token: Tokenizer.Token) bool {
return switch (token) {
.close_curly_bracket, .close_parenthesis, .close_square_bracket => true,
else => false,
};
}
fn updateDepth(token: Tokenizer.Token, depth: *usize) void {
if (isBlockStart(token)) {
depth.* += 1;
return;
}
if (isBlockEnd(token)) {
if (depth.* > 0) depth.* -= 1;
}
}
fn isImportantPair(prev_sig: ?TokenSpan, last_sig: TokenSpan) bool {
if (!isIdentImportant(last_sig.token)) return false;
const prev = prev_sig orelse return false;
return isBang(prev.token);
}
fn isIdentImportant(token: Tokenizer.Token) bool {
return switch (token) {
.ident => |name| std.ascii.eqlIgnoreCase(name, "important"),
else => false,
};
}
fn isBang(token: Tokenizer.Token) bool {
return switch (token) {
.delim => |c| c == '!',
else => false,
};
}

View File

@@ -644,8 +644,10 @@ fn consumeNumeric(self: *Tokenizer) Token {
fn consumeUnquotedUrl(self: *Tokenizer) ?Token {
// TODO: true url parser
if (self.nextByte()) |it| {
self.consumeString(it == '\'');
return self.consumeString(it == '\'');
}
return null;
}
fn consumeIdentLike(self: *Tokenizer) Token {

View File

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

View File

@@ -21,18 +21,46 @@ const js = @import("js.zig");
const v8 = js.v8;
const Array = @This();
js_arr: v8.Array,
context: *js.Context,
local: *const js.Local,
handle: *const v8.Array,
pub fn len(self: Array) usize {
return @intCast(self.js_arr.length());
return v8.v8__Array__Length(self.handle);
}
pub fn get(self: Array, index: usize) !js.Value {
const idx_key = v8.Integer.initU32(self.context.isolate, @intCast(index));
const js_obj = self.js_arr.castTo(v8.Object);
pub fn get(self: Array, index: u32) !js.Value {
const ctx = self.local.ctx;
const idx = js.Integer.init(ctx.isolate.handle, index);
const handle = v8.v8__Object__Get(@ptrCast(self.handle), self.local.handle, idx.handle) orelse {
return error.JsException;
};
return .{
.context = self.context,
.js_val = try js_obj.getValue(self.context.v8_context, idx_key.toValue()),
.local = self.local,
.handle = handle,
};
}
pub fn set(self: Array, index: u32, value: anytype, comptime opts: js.Caller.CallOpts) !bool {
const js_value = try self.local.zigValueToJs(value, opts);
var out: v8.MaybeBool = undefined;
v8.v8__Object__SetAtIndex(@ptrCast(self.handle), self.local.handle, index, js_value.handle, &out);
return out.has_value;
}
pub fn toObject(self: Array) js.Object {
return .{
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
pub fn toValue(self: Array) js.Value {
return .{
.local = self.local,
.handle = @ptrCast(self.handle),
};
}

41
src/browser/js/BigInt.zig Normal file
View File

@@ -0,0 +1,41 @@
// 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 js = @import("js.zig");
const v8 = js.v8;
const BigInt = @This();
handle: *const v8.Integer,
pub fn init(isolate: *v8.Isolate, val: anytype) BigInt {
const handle = switch (@TypeOf(val)) {
i8, i16, i32, i64, isize => v8.v8__BigInt__New(isolate, val).?,
u8, u16, u32, u64, usize => v8.v8__BigInt__NewFromUnsigned(isolate, val).?,
else => |T| @compileError("cannot create v8::BigInt from: " ++ @typeName(T)),
};
return .{ .handle = handle };
}
pub fn getInt64(self: BigInt) i64 {
return v8.v8__BigInt__Int64Value(self.handle, null);
}
pub fn getUint64(self: BigInt) u64 {
return v8.v8__BigInt__Uint64Value(self.handle, null);
}

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -17,51 +17,52 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const js = @import("js.zig");
const v8 = js.v8;
const Context = @import("Context.zig");
const string = @import("../../string.zig");
const Page = @import("../Page.zig");
const Allocator = std.mem.Allocator;
const js = @import("js.zig");
const Context = @import("Context.zig");
const TaggedOpaque = @import("TaggedOpaque.zig");
const v8 = js.v8;
const ArenaAllocator = std.heap.ArenaAllocator;
const CALL_ARENA_RETAIN = 1024 * 16;
const IS_DEBUG = @import("builtin").mode == .Debug;
// Responsible for calling Zig functions from JS invocations. This could
// probably just contained in ExecutionWorld, but having this specific logic, which
// is somewhat repetitive between constructors, functions, getters, etc contained
// here does feel like it makes it cleaner.
const Caller = @This();
context: *Context,
v8_context: v8.Context,
isolate: v8.Isolate,
call_arena: Allocator,
local: js.Local,
prev_local: ?*const js.Local,
prev_context: *Context,
// info is a v8.PropertyCallbackInfo or a v8.FunctionCallback
// All we really want from it is the isolate.
// executor = Isolate -> getCurrentContext -> getEmbedderData()
pub fn init(info: anytype) Caller {
const isolate = info.getIsolate();
const v8_context = isolate.getCurrentContext();
const context: *Context = @ptrFromInt(v8_context.getEmbedderData(1).castTo(v8.BigInt).getUint64());
// Takes the raw v8 isolate and extracts the context from it.
pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void {
const v8_context_handle = v8.v8__Isolate__GetCurrentContext(v8_isolate);
context.call_depth += 1;
return .{
.context = context,
.isolate = isolate,
.v8_context = v8_context,
.call_arena = context.call_arena,
const embedder_data = v8.v8__Context__GetEmbedderData(v8_context_handle, 1);
var lossless: bool = undefined;
const ctx: *Context = @ptrFromInt(v8.v8__BigInt__Uint64Value(embedder_data, &lossless));
ctx.call_depth += 1;
self.* = Caller{
.local = .{
.ctx = ctx,
.handle = v8_context_handle.?,
.call_arena = ctx.call_arena,
.isolate = .{ .handle = v8_isolate },
},
.prev_local = ctx.local,
.prev_context = ctx.page.js,
};
ctx.page.js = ctx;
ctx.local = &self.local;
}
pub fn deinit(self: *Caller) void {
const context = self.context;
const call_depth = context.call_depth - 1;
const ctx = self.local.ctx;
const call_depth = ctx.call_depth - 1;
// Because of callbacks, calls can be nested. Because of this, we
// can't clear the call_arena after _every_ call. Imagine we have
@@ -75,11 +76,13 @@ pub fn deinit(self: *Caller) void {
// Therefore, we keep a call_depth, and only reset the call_arena
// when a top-level (call_depth == 0) function ends.
if (call_depth == 0) {
const arena: *ArenaAllocator = @ptrCast(@alignCast(context.call_arena.ptr));
const arena: *ArenaAllocator = @ptrCast(@alignCast(ctx.call_arena.ptr));
_ = arena.reset(.{ .retain_with_limit = CALL_ARENA_RETAIN });
}
context.call_depth = call_depth;
ctx.call_depth = call_depth;
ctx.local = self.prev_local;
ctx.page.js = self.prev_context;
}
pub const CallOpts = struct {
@@ -88,12 +91,24 @@ pub const CallOpts = struct {
as_typed_array: bool = false,
};
pub fn constructor(self: *Caller, comptime T: type, func: anytype, info: v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
pub fn constructor(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = FunctionCallbackInfo{ .handle = handle };
if (!info.isConstructCall()) {
self.handleError(T, @TypeOf(func), error.InvalidArgument, info, opts);
return;
}
self._constructor(func, info) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
};
}
pub fn _constructor(self: *Caller, func: anytype, info: v8.FunctionCallbackInfo) !void {
fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void {
const F = @TypeOf(func);
const args = try self.getArgs(F, 0, info);
const res = @call(.auto, func, args);
@@ -102,129 +117,167 @@ pub fn _constructor(self: *Caller, func: anytype, info: v8.FunctionCallbackInfo)
@compileError(@typeName(F) ++ " has a constructor without a return type");
};
const new_this = info.getThis();
var this = new_this;
const new_this_handle = info.getThis();
var this = js.Object{ .local = &self.local, .handle = new_this_handle };
if (@typeInfo(ReturnType) == .error_union) {
const non_error_res = res catch |err| return err;
this = (try self.context.mapZigInstanceToJs(this, non_error_res)).castToObject();
this = try self.local.mapZigInstanceToJs(new_this_handle, non_error_res);
} else {
this = (try self.context.mapZigInstanceToJs(this, res)).castToObject();
this = try self.local.mapZigInstanceToJs(new_this_handle, res);
}
// If we got back a different object (existing wrapper), copy the prototype
// from new object. (this happens when we're upgrading an CustomElement)
if (this.handle != new_this.handle) {
const new_prototype = new_this.getPrototype();
_ = this.setPrototype(self.context.v8_context, new_prototype.castTo(v8.Object));
if (this.handle != new_this_handle) {
const prototype_handle = v8.v8__Object__GetPrototype(new_this_handle).?;
var out: v8.MaybeBool = undefined;
v8.v8__Object__SetPrototype(this.handle, self.local.handle, prototype_handle, &out);
if (comptime IS_DEBUG) {
std.debug.assert(out.has_value and out.value);
}
}
info.getReturnValue().set(this);
info.getReturnValue().set(this.handle);
}
pub fn method(self: *Caller, comptime T: type, func: anytype, info: v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
pub fn method(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = FunctionCallbackInfo{ .handle = handle };
self._method(T, func, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
};
}
pub fn _method(self: *Caller, comptime T: type, func: anytype, info: v8.FunctionCallbackInfo, comptime opts: CallOpts) !void {
fn _method(self: *Caller, comptime T: type, func: anytype, info: FunctionCallbackInfo, comptime opts: CallOpts) !void {
const F = @TypeOf(func);
var handle_scope: v8.HandleScope = undefined;
handle_scope.init(self.isolate);
defer handle_scope.deinit();
var args = try self.getArgs(F, 1, info);
@field(args, "0") = try Context.typeTaggedAnyOpaque(*T, info.getThis());
const js_this = info.getThis();
@field(args, "0") = try TaggedOpaque.fromJS(*T, js_this);
const res = @call(.auto, func, args);
info.getReturnValue().set(try self.context.zigValueToJs(res, opts));
const mapped = try self.local.zigValueToJs(res, opts);
const return_value = info.getReturnValue();
return_value.set(mapped);
}
pub fn function(self: *Caller, comptime T: type, func: anytype, info: v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
pub fn function(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = FunctionCallbackInfo{ .handle = handle };
self._function(func, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
};
}
pub fn _function(self: *Caller, func: anytype, info: v8.FunctionCallbackInfo, comptime opts: CallOpts) !void {
fn _function(self: *Caller, func: anytype, info: FunctionCallbackInfo, comptime opts: CallOpts) !void {
const F = @TypeOf(func);
const context = self.context;
const args = try self.getArgs(F, 0, info);
const res = @call(.auto, func, args);
info.getReturnValue().set(try context.zigValueToJs(res, opts));
info.getReturnValue().set(try self.local.zigValueToJs(res, opts));
}
pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return self._getIndex(T, func, idx, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
return v8.Intercepted.No;
// not intercepted
return 0;
};
}
pub fn _getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
fn _getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args = try self.getArgs(F, 2, info);
@field(args, "0") = try Context.typeTaggedAnyOpaque(*T, info.getThis());
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = idx;
const ret = @call(.auto, func, args);
return self.handleIndexedReturn(T, F, true, ret, info, opts);
}
pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: v8.Name, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return self._getNamedIndex(T, func, name, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
return v8.Intercepted.No;
// not intercepted
return 0;
};
}
pub fn _getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: v8.Name, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
fn _getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args = try self.getArgs(F, 2, info);
@field(args, "0") = try Context.typeTaggedAnyOpaque(*T, info.getThis());
@field(args, "1") = try self.nameToString(name);
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try self.nameToString(@TypeOf(args.@"1"), name);
const ret = @call(.auto, func, args);
return self.handleIndexedReturn(T, F, true, ret, info, opts);
}
pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: v8.Name, js_value: v8.Value, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
return self._setNamedIndex(T, func, name, js_value, info, opts) catch |err| {
pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return self._setNamedIndex(T, func, name, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
return v8.Intercepted.No;
// not intercepted
return 0;
};
}
pub fn _setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: v8.Name, js_value: v8.Value, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
fn _setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try Context.typeTaggedAnyOpaque(*T, info.getThis());
@field(args, "1") = try self.nameToString(name);
@field(args, "2") = try self.context.jsValueToZig(@TypeOf(@field(args, "2")), js_value);
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try self.nameToString(@TypeOf(args.@"1"), name);
@field(args, "2") = try self.local.jsValueToZig(@TypeOf(@field(args, "2")), js_value);
if (@typeInfo(F).@"fn".params.len == 4) {
@field(args, "3") = self.context.page;
@field(args, "3") = self.local.ctx.page;
}
const ret = @call(.auto, func, args);
return self.handleIndexedReturn(T, F, false, ret, info, opts);
}
pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: v8.Name, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return self._deleteNamedIndex(T, func, name, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
return v8.Intercepted.No;
return 0;
};
}
pub fn _deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: v8.Name, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
fn _deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try Context.typeTaggedAnyOpaque(*T, info.getThis());
@field(args, "1") = try self.nameToString(name);
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try self.nameToString(@TypeOf(args.@"1"), name);
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = self.context.page;
@field(args, "2") = self.local.ctx.page;
}
const ret = @call(.auto, func, args);
return self.handleIndexedReturn(T, F, false, ret, info, opts);
}
fn handleIndexedReturn(self: *Caller, comptime T: type, comptime F: type, comptime getter: bool, ret: anytype, info: v8.PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
fn handleIndexedReturn(self: *Caller, comptime T: type, comptime F: type, comptime getter: bool, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
// need to unwrap this error immediately for when opts.null_as_undefined == true
// and we need to compare it to null;
const non_error_ret = switch (@typeInfo(@TypeOf(ret))) {
@@ -235,20 +288,23 @@ fn handleIndexedReturn(self: *Caller, comptime T: type, comptime F: type, compti
// if error.NotHandled is part of the error set.
if (isInErrorSet(error.NotHandled, eu.error_set)) {
if (err == error.NotHandled) {
return v8.Intercepted.No;
// not intercepted
return 0;
}
}
self.handleError(T, F, err, info, opts);
return v8.Intercepted.No;
// not intercepted
return 0;
};
},
else => ret,
};
if (comptime getter) {
info.getReturnValue().set(try self.context.zigValueToJs(non_error_ret, opts));
info.getReturnValue().set(try self.local.zigValueToJs(non_error_ret, opts));
}
return v8.Intercepted.Yes;
// intercepted
return 1;
}
fn isInErrorSet(err: anyerror, comptime T: type) bool {
@@ -258,86 +314,45 @@ fn isInErrorSet(err: anyerror, comptime T: type) bool {
return false;
}
fn namedSetOrDeleteCall(res: anytype, has_value: bool) !u8 {
if (@typeInfo(@TypeOf(res)) == .error_union) {
_ = try res;
fn nameToString(self: *const Caller, comptime T: type, name: *const v8.Name) !T {
const handle = @as(*const v8.String, @ptrCast(name));
if (T == string.String) {
return js.String.toSSO(.{ .local = &self.local, .handle = handle }, false);
}
if (has_value == false) {
return v8.Intercepted.No;
if (T == string.Global) {
return js.String.toSSO(.{ .local = &self.local, .handle = handle }, true);
}
return v8.Intercepted.Yes;
}
fn nameToString(self: *Caller, name: v8.Name) ![]const u8 {
return self.context.valueToString(.{ .handle = name.handle }, .{});
}
fn isSelfReceiver(comptime T: type, comptime F: type) bool {
return checkSelfReceiver(T, F, false);
}
fn assertSelfReceiver(comptime T: type, comptime F: type) void {
_ = checkSelfReceiver(T, F, true);
}
fn checkSelfReceiver(comptime T: type, comptime F: type, comptime fail: bool) bool {
const params = @typeInfo(F).@"fn".params;
if (params.len == 0) {
if (fail) {
@compileError(@typeName(F) ++ " must have a self parameter");
}
return false;
}
const first_param = params[0].type.?;
if (first_param != *T and first_param != *const T) {
if (fail) {
@compileError(std.fmt.comptimePrint("The first parameter to {s} must be a *{s} or *const {s}. Got: {s}", .{
@typeName(F),
@typeName(T),
@typeName(T),
@typeName(first_param),
}));
}
return false;
}
return true;
}
fn assertIsPageArg(comptime T: type, comptime F: type, index: comptime_int) void {
const param = @typeInfo(F).@"fn".params[index].type.?;
if (isPage(param)) {
return;
}
@compileError(std.fmt.comptimePrint("The {d} parameter of {s}.{s} must be a *Page or *const Page. Got: {s}", .{ index, @typeName(T), @typeName(F), @typeName(param) }));
return try js.String.toSlice(.{ .local = &self.local, .handle = handle });
}
fn handleError(self: *Caller, comptime T: type, comptime F: type, err: anyerror, info: anytype, comptime opts: CallOpts) void {
const isolate = self.isolate;
const isolate = self.local.isolate;
if (comptime @import("builtin").mode == .Debug and @hasDecl(@TypeOf(info), "length")) {
if (comptime @import("builtin").mode == .Debug and @TypeOf(info) == FunctionCallbackInfo) {
if (log.enabled(.js, .warn)) {
self.logFunctionCallError(@typeName(T), @typeName(F), err, info);
}
}
var js_err: ?v8.Value = switch (err) {
error.InvalidArgument => createTypeException(isolate, "invalid argument"),
error.OutOfMemory => js._createException(isolate, "out of memory"),
error.IllegalConstructor => js._createException(isolate, "Illegal Contructor"),
const js_err: *const v8.Value = switch (err) {
error.TryCatchRethrow => return,
error.InvalidArgument => isolate.createTypeError("invalid argument"),
error.OutOfMemory => isolate.createError("out of memory"),
error.IllegalConstructor => isolate.createError("Illegal Contructor"),
else => blk: {
if (!comptime opts.dom_exception) {
break :blk null;
if (comptime opts.dom_exception) {
const DOMException = @import("../webapi/DOMException.zig");
if (DOMException.fromError(err)) |ex| {
const value = self.local.zigValueToJs(ex, .{}) catch break :blk isolate.createError("internal error");
break :blk value.handle;
}
}
const DOMException = @import("../webapi/DOMException.zig");
const ex = DOMException.fromError(err) orelse break :blk null;
break :blk self.context.zigValueToJs(ex, .{}) catch js._createException(isolate, "internal error");
break :blk isolate.createError(@errorName(err));
},
};
if (js_err == null) {
js_err = js._createException(isolate, @errorName(err));
}
const js_exception = isolate.throwException(js_err.?);
info.getReturnValue().setValueHandle(js_exception.handle);
const js_exception = isolate.throwException(js_err);
info.getReturnValue().setValueHandle(js_exception);
}
// If we call a method in javascript: cat.lives('nine');
@@ -354,7 +369,7 @@ fn handleError(self: *Caller, comptime T: type, comptime F: type, err: anyerror,
// the last parameter in Zig is an array, we'll try to slurp the additional
// parameters into the array.
fn getArgs(self: *const Caller, comptime F: type, comptime offset: usize, info: anytype) !ParameterTypes(F) {
const context = self.context;
const local = &self.local;
var args: ParameterTypes(F) = undefined;
const params = @typeInfo(F).@"fn".params[offset..];
@@ -369,25 +384,7 @@ fn getArgs(self: *const Caller, comptime F: type, comptime offset: usize, info:
// from our params slice, because we don't want to bind it to
// a JS argument
if (comptime isPage(params[params.len - 1].type.?)) {
@field(args, tupleFieldName(params.len - 1 + offset)) = self.context.page;
break :blk params[0 .. params.len - 1];
}
// If the last parameter is a special JsThis, set it, and exclude it
// from our params slice, because we don't want to bind it to
// a JS argument
if (comptime params[params.len - 1].type.? == js.This) {
@field(args, tupleFieldName(params.len - 1 + offset)) = .{ .obj = .{
.context = context,
.js_obj = info.getThis(),
} };
// AND the 2nd last parameter is state
if (params.len > 1 and comptime isPage(params[params.len - 2].type.?)) {
@field(args, tupleFieldName(params.len - 2 + offset)) = self.context.page;
break :blk params[0 .. params.len - 2];
}
@field(args, tupleFieldName(params.len - 1 + offset)) = local.ctx.page;
break :blk params[0 .. params.len - 1];
}
@@ -413,16 +410,15 @@ fn getArgs(self: *const Caller, comptime F: type, comptime offset: usize, info:
const last_parameter_type_info = @typeInfo(last_parameter_type);
if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) {
const slice_type = last_parameter_type_info.pointer.child;
const corresponding_js_value = info.getArg(@as(u32, @intCast(last_js_parameter)));
const corresponding_js_value = info.getArg(@intCast(last_js_parameter), local);
if (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8) {
is_variadic = true;
if (js_parameter_count == 0) {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
} else if (js_parameter_count >= params_to_map.len) {
const arr = try self.call_arena.alloc(last_parameter_type_info.pointer.child, js_parameter_count - params_to_map.len + 1);
const arr = try local.call_arena.alloc(last_parameter_type_info.pointer.child, js_parameter_count - params_to_map.len + 1);
for (arr, last_js_parameter..) |*a, i| {
const js_value = info.getArg(@as(u32, @intCast(i)));
a.* = try context.jsValueToZig(slice_type, js_value);
a.* = try local.jsValueToZig(slice_type, info.getArg(@intCast(i), local));
}
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = arr;
} else {
@@ -442,16 +438,14 @@ fn getArgs(self: *const Caller, comptime F: type, comptime offset: usize, info:
if (comptime isPage(param.type.?)) {
@compileError("Page must be the last parameter (or 2nd last if there's a JsThis): " ++ @typeName(F));
} else if (comptime param.type.? == js.This) {
@compileError("JsThis must be the last parameter: " ++ @typeName(F));
} else if (i >= js_parameter_count) {
if (@typeInfo(param.type.?) != .optional) {
return error.InvalidArgument;
}
@field(args, tupleFieldName(field_index)) = null;
} else {
const js_value = info.getArg(@as(u32, @intCast(i)));
@field(args, tupleFieldName(field_index)) = context.jsValueToZig(param.type.?, js_value) catch {
const js_val = info.getArg(@intCast(i), local);
@field(args, tupleFieldName(field_index)) = local.jsValueToZig(param.type.?, js_val) catch {
return error.InvalidArgument;
};
}
@@ -462,25 +456,26 @@ fn getArgs(self: *const Caller, comptime F: type, comptime offset: usize, info:
// This is extracted to speed up compilation. When left inlined in handleError,
// this can add as much as 10 seconds of compilation time.
fn logFunctionCallError(self: *Caller, type_name: []const u8, func: []const u8, err: anyerror, info: v8.FunctionCallbackInfo) void {
fn logFunctionCallError(self: *Caller, type_name: []const u8, func: []const u8, err: anyerror, info: FunctionCallbackInfo) void {
const args_dump = self.serializeFunctionArgs(info) catch "failed to serialize args";
log.info(.js, "function call error", .{
.type = type_name,
.func = func,
.err = err,
.args = args_dump,
.stack = self.context.stackTrace() catch |err1| @errorName(err1),
.stack = self.local.stackTrace() catch |err1| @errorName(err1),
});
}
fn serializeFunctionArgs(self: *Caller, info: v8.FunctionCallbackInfo) ![]const u8 {
const context = self.context;
var buf = std.Io.Writer.Allocating.init(context.call_arena);
fn serializeFunctionArgs(self: *Caller, info: FunctionCallbackInfo) ![]const u8 {
const local = &self.local;
var buf = std.Io.Writer.Allocating.init(local.call_arena);
const separator = log.separator();
for (0..info.length()) |i| {
try buf.writer.print("{s}{d} - ", .{ separator, i + 1 });
try context.debugValue(info.getArg(@intCast(i)), &buf.writer);
const js_value = info.getArg(@intCast(i), local);
try local.debugValue(js_value, &buf.writer);
}
return buf.written();
}
@@ -529,6 +524,64 @@ fn isPage(comptime T: type) bool {
return T == *Page or T == *const Page;
}
fn createTypeException(isolate: v8.Isolate, msg: []const u8) v8.Value {
return v8.Exception.initTypeError(v8.String.initUtf8(isolate, msg));
}
// These wrap the raw v8 C API to provide a cleaner interface.
pub const FunctionCallbackInfo = struct {
handle: *const v8.FunctionCallbackInfo,
pub fn length(self: FunctionCallbackInfo) u32 {
return @intCast(v8.v8__FunctionCallbackInfo__Length(self.handle));
}
pub fn getArg(self: FunctionCallbackInfo, index: u32, local: *const js.Local) js.Value {
return .{ .local = local, .handle = v8.v8__FunctionCallbackInfo__INDEX(self.handle, @intCast(index)).? };
}
pub fn getThis(self: FunctionCallbackInfo) *const v8.Object {
return v8.v8__FunctionCallbackInfo__This(self.handle).?;
}
pub fn getReturnValue(self: FunctionCallbackInfo) ReturnValue {
var rv: v8.ReturnValue = undefined;
v8.v8__FunctionCallbackInfo__GetReturnValue(self.handle, &rv);
return .{ .handle = rv };
}
fn isConstructCall(self: FunctionCallbackInfo) bool {
return v8.v8__FunctionCallbackInfo__IsConstructCall(self.handle);
}
};
pub const PropertyCallbackInfo = struct {
handle: *const v8.PropertyCallbackInfo,
pub fn getThis(self: PropertyCallbackInfo) *const v8.Object {
return v8.v8__PropertyCallbackInfo__This(self.handle).?;
}
pub fn getReturnValue(self: PropertyCallbackInfo) ReturnValue {
var rv: v8.ReturnValue = undefined;
v8.v8__PropertyCallbackInfo__GetReturnValue(self.handle, &rv);
return .{ .handle = rv };
}
};
const ReturnValue = struct {
handle: v8.ReturnValue,
pub fn set(self: ReturnValue, value: anytype) void {
const T = @TypeOf(value);
if (T == *const v8.Object) {
self.setValueHandle(@ptrCast(value));
} else if (T == *const v8.Value) {
self.setValueHandle(value);
} else if (T == js.Value) {
self.setValueHandle(value.handle);
} else {
@compileError("Unsupported type for ReturnValue.set: " ++ @typeName(T));
}
}
pub fn setValueHandle(self: ReturnValue, handle: *const v8.Value) void {
v8.v8__ReturnValue__Set(self.handle, handle);
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -18,20 +18,26 @@
const std = @import("std");
const js = @import("js.zig");
const builtin = @import("builtin");
const v8 = js.v8;
const App = @import("../../App.zig");
const log = @import("../../log.zig");
const bridge = @import("bridge.zig");
const Context = @import("Context.zig");
const Isolate = @import("Isolate.zig");
const Platform = @import("Platform.zig");
const Snapshot = @import("Snapshot.zig");
const Inspector = @import("Inspector.zig");
const ExecutionWorld = @import("ExecutionWorld.zig");
const Page = @import("../Page.zig");
const Window = @import("../webapi/Window.zig");
const JsApis = bridge.JsApis;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const IS_DEBUG = builtin.mode == .Debug;
// The Env maps to a V8 isolate, which represents a isolated sandbox for
// executing JavaScript. The Env is where we'll define our V8 <-> Zig bindings,
@@ -41,118 +47,308 @@ 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: v8.Isolate,
isolate: js.Isolate,
contexts: std.ArrayList(*js.Context),
// just kept around because we need to free it on deinit
isolate_params: *v8.CreateParams,
context_id: usize,
// Dynamic slice to avoid circular dependency on JsApis.len at comptime
templates: []v8.FunctionTemplate,
// Global handles that need to be freed on deinit
eternal_function_templates: []v8.Eternal,
// Dynamic slice to avoid circular dependency on JsApis.len at comptime
templates: []*const v8.FunctionTemplate,
// Global template created once per isolate and reused across all contexts
global_template: v8.Eternal,
// Inspector associated with the Isolate. Exists when CDP is being used.
inspector: ?*Inspector,
pub const InitOpts = struct {
with_inspector: bool = false,
};
pub fn init(app: *App, opts: InitOpts) !Env {
const allocator = app.allocator;
const snapshot = &app.snapshot;
pub fn init(allocator: Allocator, platform: *const Platform, snapshot: *Snapshot) !Env {
var params = try allocator.create(v8.CreateParams);
errdefer allocator.destroy(params);
v8.c.v8__Isolate__CreateParams__CONSTRUCT(params);
v8.v8__Isolate__CreateParams__CONSTRUCT(params);
params.snapshot_blob = @ptrCast(&snapshot.startup_data);
params.array_buffer_allocator = v8.createDefaultArrayBufferAllocator();
errdefer v8.destroyArrayBufferAllocator(params.array_buffer_allocator.?);
params.array_buffer_allocator = v8.v8__ArrayBuffer__Allocator__NewDefaultAllocator().?;
errdefer v8.v8__ArrayBuffer__Allocator__DELETE(params.array_buffer_allocator.?);
params.external_references = &snapshot.external_references;
var isolate = v8.Isolate.init(params);
var isolate = js.Isolate.init(params);
errdefer isolate.deinit();
const isolate_handle = isolate.handle;
// This is the callback that runs whenever a module is dynamically imported.
isolate.setHostImportModuleDynamicallyCallback(Context.dynamicModuleCallback);
isolate.setPromiseRejectCallback(promiseRejectCallback);
isolate.setMicrotasksPolicy(v8.c.kExplicit);
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();
isolate.setHostInitializeImportMetaObjectCallback(Context.metaObjectCallback);
v8.v8__Isolate__SetHostInitializeImportMetaObjectCallback(isolate_handle, Context.metaObjectCallback);
// Allocate templates array dynamically to avoid comptime dependency on JsApis.len
const templates = try allocator.alloc(v8.FunctionTemplate, JsApis.len);
// Allocate arrays dynamically to avoid comptime dependency on JsApis.len
const eternal_function_templates = try allocator.alloc(v8.Eternal, JsApis.len);
errdefer allocator.free(eternal_function_templates);
const templates = try allocator.alloc(*const v8.FunctionTemplate, JsApis.len);
errdefer allocator.free(templates);
var global_eternal: v8.Eternal = undefined;
{
var temp_scope: v8.HandleScope = undefined;
v8.HandleScope.init(&temp_scope, isolate);
var temp_scope: js.HandleScope = undefined;
temp_scope.init(isolate);
defer temp_scope.deinit();
const context = v8.Context.init(isolate, null, null);
context.enter();
defer context.exit();
inline for (JsApis, 0..) |JsApi, i| {
JsApi.Meta.class_id = i;
const data = context.getDataFromSnapshotOnce(snapshot.data_start + i);
const function = v8.FunctionTemplate{ .handle = @ptrCast(data) };
templates[i] = v8.Persistent(v8.FunctionTemplate).init(isolate, function).castToFunctionTemplate();
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]);
// Extract the local handle from the global for easy access
const eternal_ptr = v8.v8__Eternal__Get(&eternal_function_templates[i], isolate_handle);
templates[i] = @ptrCast(@alignCast(eternal_ptr.?));
}
// Create global template once per isolate
const js_global = v8.v8__FunctionTemplate__New__DEFAULT(isolate_handle);
const window_name = v8.v8__String__NewFromUtf8(isolate_handle, "Window", v8.kNormal, 6);
v8.v8__FunctionTemplate__SetClassName(js_global, window_name);
// Find Window in JsApis by name (avoids circular import)
const window_index = comptime bridge.JsApiLookup.getId(Window.JsApi);
v8.v8__FunctionTemplate__Inherit(js_global, templates[window_index]);
const global_template_local = v8.v8__FunctionTemplate__InstanceTemplate(js_global).?;
v8.v8__ObjectTemplate__SetNamedHandler(global_template_local, &.{
.getter = bridge.unknownWindowPropertyCallback,
.setter = null,
.query = null,
.deleter = null,
.enumerator = null,
.definer = null,
.descriptor = null,
.data = null,
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
});
v8.v8__Eternal__New(isolate_handle, @ptrCast(global_template_local), &global_eternal);
}
var inspector: ?*js.Inspector = null;
if (opts.with_inspector) {
inspector = try Inspector.init(allocator, isolate_handle);
}
return .{
.app = app,
.context_id = 0,
.contexts = .empty,
.isolate = isolate,
.platform = platform,
.allocator = allocator,
.platform = &app.platform,
.templates = templates,
.isolate_params = params,
.inspector = inspector,
.eternal_function_templates = eternal_function_templates,
.global_template = global_eternal,
};
}
pub fn deinit(self: *Env) void {
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.destroyArrayBufferAllocator(self.isolate_params.array_buffer_allocator.?);
self.allocator.destroy(self.isolate_params);
self.allocator.free(self.templates);
v8.v8__ArrayBuffer__Allocator__DELETE(self.isolate_params.array_buffer_allocator.?);
allocator.destroy(self.isolate_params);
}
pub fn newInspector(self: *Env, arena: Allocator, ctx: anytype) !Inspector {
return Inspector.init(arena, self.isolate, ctx);
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 {
return self.platform.inner.pumpMessageLoop(self.isolate, false);
var hs: v8.HandleScope = undefined;
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle);
defer v8.v8__HandleScope__DESTRUCT(&hs);
return v8.v8__Platform__PumpMessageLoop(self.platform.handle, self.isolate.handle, false);
}
pub fn runIdleTasks(self: *const Env) void {
return self.platform.inner.runIdleTasks(self.isolate, 1);
}
pub fn newExecutionWorld(self: *Env) !ExecutionWorld {
return .{
.env = self,
.context = null,
.context_arena = ArenaAllocator.init(self.allocator),
};
v8.v8__Platform__RunIdleTasks(self.platform.handle, self.isolate.handle, 1);
}
// V8 doesn't immediately free memory associated with
// a Context, it's managed by the garbage collector. We use the
// `lowMemoryNotification` call on the isolate to encourage v8 to free
// any contexts which have been freed.
// This GC is very aggressive. Use memoryPressureNotification for less
// aggressive GC passes.
pub fn lowMemoryNotification(self: *Env) void {
var handle_scope: v8.HandleScope = undefined;
v8.HandleScope.init(&handle_scope, self.isolate);
var handle_scope: js.HandleScope = undefined;
handle_scope.init(self.isolate);
defer handle_scope.deinit();
self.isolate.lowMemoryNotification();
}
// V8 doesn't immediately free memory associated with
// a Context, it's managed by the garbage collector. We use the
// `memoryPressureNotification` call on the isolate to encourage v8 to free
// any contexts which have been freed.
// The level indicates the aggressivity of the GC required:
// moderate speeds up incremental GC
// critical runs one full GC
// For a more aggressive GC, use lowMemoryNotification.
pub fn memoryPressureNotification(self: *Env, level: Isolate.MemoryPressureLevel) void {
var handle_scope: js.HandleScope = undefined;
handle_scope.init(self.isolate);
defer handle_scope.deinit();
self.isolate.memoryPressureNotification(level);
}
pub fn dumpMemoryStats(self: *Env) void {
const stats = self.isolate.getHeapStatistics();
std.debug.print(
@@ -174,20 +370,38 @@ pub fn dumpMemoryStats(self: *Env) void {
, .{ stats.total_heap_size, stats.total_heap_size_executable, stats.total_physical_size, stats.total_available_size, stats.used_heap_size, stats.heap_size_limit, stats.malloced_memory, stats.external_memory, stats.peak_malloced_memory, stats.number_of_native_contexts, stats.number_of_detached_contexts, stats.total_global_handles_size, stats.used_global_handles_size, stats.does_zap_garbage });
}
fn promiseRejectCallback(v8_msg: v8.C_PromiseRejectMessage) callconv(.c) void {
const msg = v8.PromiseRejectMessage.initFromC(v8_msg);
const isolate = msg.getPromise().toObject().getIsolate();
const context = Context.fromIsolate(isolate);
fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) void {
const promise_handle = v8.v8__PromiseRejectMessage__GetPromise(&message_handle).?;
const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?;
const js_isolate = js.Isolate{ .handle = v8_isolate };
const ctx = Context.fromIsolate(js_isolate);
const value =
if (msg.getValue()) |v8_value|
context.valueToString(v8_value, .{}) catch |err| @errorName(err)
else
"no value";
const local = js.Local{
.ctx = ctx,
.isolate = js_isolate,
.handle = v8.v8__Isolate__GetCurrentContext(v8_isolate).?,
.call_arena = ctx.call_arena,
};
log.debug(.js, "unhandled rejection", .{
.value = value,
.stack = context.stackTrace() catch |err| @errorName(err) orelse "???",
.note = "This should be updated to call window.unhandledrejection",
});
const page = ctx.page;
page.window.unhandledPromiseRejection(.{
.local = &local,
.handle = &message_handle,
}, page) catch |err| {
log.warn(.browser, "unhandled rejection handler", .{ .err = err });
};
}
fn fatalCallback(c_location: [*c]const u8, c_message: [*c]const u8) callconv(.c) void {
const location = std.mem.span(c_location);
const message = std.mem.span(c_message);
log.fatal(.app, "V8 fatal callback", .{ .location = location, .message = message });
@import("../../crash_handler.zig").crash("Fatal V8 Error", .{ .location = location, .message = message }, @returnAddress());
}
fn oomCallback(c_location: [*c]const u8, details: ?*const v8.OOMDetails) callconv(.c) void {
const location = std.mem.span(c_location);
const detail = if (details) |d| std.mem.span(d.detail) else "";
log.fatal(.app, "V8 OOM", .{ .location = location, .detail = detail });
@import("../../crash_handler.zig").crash("V8 OOM", .{ .location = location, .detail = detail }, @returnAddress());
}

View File

@@ -1,197 +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 IS_DEBUG = @import("builtin").mode == .Debug;
const log = @import("../../log.zig");
const js = @import("js.zig");
const v8 = js.v8;
const Env = @import("Env.zig");
const Context = @import("Context.zig");
const Page = @import("../Page.zig");
const ArenaAllocator = std.heap.ArenaAllocator;
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 v8.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 {
std.debug.assert(self.context == null);
const env = self.env;
const isolate = env.isolate;
var v8_context: v8.Context = blk: {
var temp_scope: v8.HandleScope = undefined;
v8.HandleScope.init(&temp_scope, isolate);
defer temp_scope.deinit();
if (comptime IS_DEBUG) {
// Getting this into the snapshot is tricky (anything involving the
// global is tricky). Easier to do here, and in debug mode, we're
// fine with paying the small perf hit.
const js_global = v8.FunctionTemplate.initDefault(isolate);
const global_template = js_global.getInstanceTemplate();
global_template.setNamedProperty(v8.NamedPropertyHandlerConfiguration{
.getter = unknownPropertyCallback,
.flags = v8.PropertyHandlerFlags.NonMasking | v8.PropertyHandlerFlags.OnlyInterceptStrings,
}, null);
}
const context_local = v8.Context.init(isolate, null, null);
const v8_context = v8.Persistent(v8.Context).init(isolate, context_local).castToContext();
break :blk v8_context;
};
// For a Page we only create one HandleScope, it is stored in the main World (enter==true). A page can have multple contexts, 1 for each World.
// The main Context that enters and holds the HandleScope should therefore always be created first. Following other worlds for this page
// like isolated Worlds, will thereby place their objects on the main page's HandleScope. Note: In the furure the number of context will multiply multiple frames support
var handle_scope: ?v8.HandleScope = null;
if (enter) {
handle_scope = @as(v8.HandleScope, undefined);
v8.HandleScope.init(&handle_scope.?, isolate);
v8_context.enter();
}
errdefer if (enter) {
v8_context.exit();
handle_scope.?.deinit();
};
const context_id = env.context_id;
env.context_id = context_id + 1;
self.context = Context{
.page = page,
.id = context_id,
.isolate = isolate,
.v8_context = v8_context,
.templates = env.templates,
.handle_scope = handle_scope,
.script_manager = &page._script_manager,
.call_arena = page.call_arena,
.arena = self.context_arena.allocator(),
};
var context = &self.context.?;
// Store a pointer to our context inside the v8 context so that, given
// a v8 context, we can get our context out
const data = isolate.initBigIntU64(@intCast(@intFromPtr(context)));
v8_context.setEmbedderData(1, data);
try context.setupGlobal();
return context;
}
pub fn removeContext(self: *ExecutionWorld) void {
// Force running the micro task to drain the queue before reseting the
// context arena.
// Tasks in the queue are relying to the arena memory could be present in
// the queue. Running them later could lead to invalid memory accesses.
self.env.runMicrotasks();
self.context.?.deinit();
self.context = null;
_ = self.context_arena.reset(.{ .retain_with_limit = CONTEXT_ARENA_RETAIN });
}
pub fn terminateExecution(self: *const ExecutionWorld) void {
self.env.isolate.terminateExecution();
}
pub fn resumeExecution(self: *const ExecutionWorld) void {
self.env.isolate.cancelTerminateExecution();
}
pub fn unknownPropertyCallback(c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 {
const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
const context = Context.fromIsolate(info.getIsolate());
const property = context.valueToString(.{ .handle = c_name.? }, .{}) catch "???";
const ignored = std.StaticStringMap(void).initComptime(.{
.{ "process", {} },
.{ "ShadyDOM", {} },
.{ "ShadyCSS", {} },
.{ "litNonce", {} },
.{ "litHtmlVersions", {} },
.{ "litElementVersions", {} },
.{ "litHtmlPolyfillSupport", {} },
.{ "litElementHydrateSupport", {} },
.{ "litElementPolyfillSupport", {} },
.{ "reactiveElementVersions", {} },
.{ "recaptcha", {} },
.{ "grecaptcha", {} },
.{ "___grecaptcha_cfg", {} },
.{ "__recaptcha_api", {} },
.{ "__google_recaptcha_client", {} },
.{ "CLOSURE_FLAGS", {} },
});
if (!ignored.has(property)) {
log.debug(.unknown_prop, "unkown global property", .{
.info = "but the property can exist in pure JS",
.stack = context.stackTrace() catch "???",
.property = property,
});
}
return v8.Intercepted.No;
}

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,148 +20,237 @@ const std = @import("std");
const js = @import("js.zig");
const v8 = js.v8;
const PersistentFunction = v8.Persistent(v8.Function);
const Allocator = std.mem.Allocator;
const log = @import("../../log.zig");
const Function = @This();
id: usize,
context: *js.Context,
this: ?v8.Object = null,
func: PersistentFunction,
local: *const js.Local,
this: ?*const v8.Object = null,
handle: *const v8.Function,
pub const Result = struct {
stack: ?[]const u8,
exception: []const u8,
};
pub fn getName(self: *const Function, allocator: Allocator) ![]const u8 {
const name = self.func.castToFunction().getName();
return self.context.valueToString(name, .{ .allocator = allocator });
}
pub fn setName(self: *const Function, name: []const u8) void {
const v8_name = v8.String.initUtf8(self.context.isolate, name);
self.func.castToFunction().setName(v8_name);
}
pub fn withThis(self: *const Function, value: anytype) !Function {
const local = self.local;
const this_obj = if (@TypeOf(value) == js.Object)
value.js_obj
value.handle
else
(try self.context.zigValueToJs(value, .{})).castTo(v8.Object);
(try local.zigValueToJs(value, .{})).handle;
return .{
.id = self.id,
.local = local,
.this = this_obj,
.func = self.func,
.context = self.context,
.handle = self.handle,
};
}
pub fn newInstance(self: *const Function, result: *Result) !js.Object {
const context = self.context;
pub fn newInstance(self: *const Function, caught: *js.TryCatch.Caught) !js.Object {
const local = self.local;
var try_catch: js.TryCatch = undefined;
try_catch.init(context);
try_catch.init(local);
defer try_catch.deinit();
// This creates a new instance using this Function as a constructor.
// This returns a generic Object
const js_obj = self.func.castToFunction().initInstance(context.v8_context, &.{}) orelse {
if (try_catch.hasCaught()) {
const allocator = context.call_arena;
result.stack = try_catch.stack(allocator) catch null;
result.exception = (try_catch.exception(allocator) catch "???") orelse "???";
} else {
result.stack = null;
result.exception = "???";
}
// const c_args = @as(?[*]const ?*c.Value, @ptrCast(&.{}));
const handle = v8.v8__Function__NewInstance(self.handle, local.handle, 0, null) orelse {
caught.* = try_catch.caughtOrError(local.call_arena, error.Unknown);
return error.JsConstructorFailed;
};
return .{
.context = context,
.js_obj = js_obj,
.local = local,
.handle = handle,
};
}
pub fn call(self: *const Function, comptime T: type, args: anytype) !T {
return self.callWithThis(T, self.getThis(), args);
var caught: js.TryCatch.Caught = undefined;
return self._tryCallWithThis(T, self.getThis(), args, &caught, .{}) catch |err| {
log.warn(.js, "call caught", .{ .err = err, .caught = caught });
return err;
};
}
pub fn tryCall(self: *const Function, comptime T: type, args: anytype, result: *Result) !T {
return self.tryCallWithThis(T, self.getThis(), args, result);
}
pub fn tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype, result: *Result) !T {
var try_catch: js.TryCatch = undefined;
try_catch.init(self.context);
defer try_catch.deinit();
return self.callWithThis(T, this, args) catch |err| {
if (try_catch.hasCaught()) {
const allocator = self.context.call_arena;
result.stack = try_catch.stack(allocator) catch null;
result.exception = (try_catch.exception(allocator) catch @errorName(err)) orelse @errorName(err);
} else {
result.stack = null;
result.exception = @errorName(err);
}
pub fn callRethrow(self: *const Function, comptime T: type, args: anytype) !T {
var caught: js.TryCatch.Caught = undefined;
return self._tryCallWithThis(T, self.getThis(), args, &caught, .{ .rethrow = true }) catch |err| {
log.warn(.js, "call caught", .{ .err = err, .caught = caught });
return err;
};
}
pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype) !T {
const context = self.context;
var caught: js.TryCatch.Caught = undefined;
return self._tryCallWithThis(T, this, args, &caught, .{}) catch |err| {
log.warn(.js, "callWithThis caught", .{ .err = err, .caught = caught });
return err;
};
}
const js_this = try context.valueToExistingObject(this);
pub fn tryCall(self: *const Function, comptime T: type, args: anytype, caught: *js.TryCatch.Caught) !T {
return self._tryCallWithThis(T, self.getThis(), args, caught, .{});
}
pub fn tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype, caught: *js.TryCatch.Caught) !T {
return self._tryCallWithThis(T, this, args, caught, .{});
}
const CallOpts = struct {
rethrow: bool = false,
};
fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype, caught: *js.TryCatch.Caught, comptime opts: CallOpts) !T {
caught.* = .{};
const local = self.local;
// When we're calling a function from within JavaScript itself, this isn't
// necessary. We're within a Caller instantiation, which will already have
// incremented the call_depth and it won't decrement it until the Caller is
// done.
// But some JS functions are initiated from Zig code, and not v8. For
// example, Observers, some event and window callbacks. In those cases, we
// need to increase the call_depth so that the call_arena remains valid for
// the duration of the function call. If we don't do this, the call_arena
// will be reset after each statement of the function which executes Zig code.
const ctx = local.ctx;
const call_depth = ctx.call_depth;
ctx.call_depth = call_depth + 1;
defer ctx.call_depth = call_depth;
const js_this = blk: {
if (@TypeOf(this) == js.Object) {
break :blk this;
}
break :blk try local.zigValueToJs(this, .{});
};
const aargs = if (comptime @typeInfo(@TypeOf(args)) == .null) struct {}{} else args;
const js_args: []const v8.Value = switch (@typeInfo(@TypeOf(aargs))) {
const js_args: []const *const v8.Value = switch (@typeInfo(@TypeOf(aargs))) {
.@"struct" => |s| blk: {
const fields = s.fields;
var js_args: [fields.len]v8.Value = undefined;
var js_args: [fields.len]*const v8.Value = undefined;
inline for (fields, 0..) |f, i| {
js_args[i] = try context.zigValueToJs(@field(aargs, f.name), .{});
js_args[i] = (try local.zigValueToJs(@field(aargs, f.name), .{})).handle;
}
const cargs: [fields.len]v8.Value = js_args;
const cargs: [fields.len]*const v8.Value = js_args;
break :blk &cargs;
},
.pointer => blk: {
var values = try context.call_arena.alloc(v8.Value, args.len);
var values = try local.call_arena.alloc(*const v8.Value, args.len);
for (args, 0..) |a, i| {
values[i] = try context.zigValueToJs(a, .{});
values[i] = (try local.zigValueToJs(a, .{})).handle;
}
break :blk values;
},
else => @compileError("JS Function called with invalid paremter type"),
};
const result = self.func.castToFunction().call(context.v8_context, js_this, js_args);
if (result == null) {
// std.debug.print("CB ERR: {s}\n", .{self.src() catch "???"});
return error.JSExecCallback;
}
const c_args = @as(?[*]const ?*v8.Value, @ptrCast(js_args.ptr));
if (@typeInfo(T) == .void) return {};
return context.jsValueToZig(T, result.?);
var try_catch: js.TryCatch = undefined;
try_catch.init(local);
defer try_catch.deinit();
const handle = v8.v8__Function__Call(self.handle, local.handle, js_this.handle, @as(c_int, @intCast(js_args.len)), c_args) orelse {
if ((comptime opts.rethrow) and try_catch.hasCaught()) {
try_catch.rethrow();
return error.TryCatchRethrow;
}
caught.* = try_catch.caughtOrError(local.call_arena, error.JSExecCallback);
return error.JSExecCallback;
};
if (@typeInfo(T) == .void) {
return {};
}
return local.jsValueToZig(T, .{ .local = local, .handle = handle });
}
fn getThis(self: *const Function) v8.Object {
return self.this orelse self.context.v8_context.getGlobal();
fn getThis(self: *const Function) js.Object {
const handle = if (self.this) |t| t else v8.v8__Context__Global(self.local.handle).?;
return .{
.local = self.local,
.handle = handle,
};
}
pub fn src(self: *const Function) ![]const u8 {
const value = self.func.castToFunction().toValue();
return self.context.valueToString(value, .{});
return self.local.valueToString(.{ .local = self.local, .handle = @ptrCast(self.handle) }, .{});
}
pub fn getPropertyValue(self: *const Function, name: []const u8) !?js.Value {
const func_obj = self.func.castToFunction().toObject();
const key = v8.String.initUtf8(self.context.isolate, name);
const value = func_obj.getValue(self.context.v8_context, key) catch return null;
return self.context.createValue(value);
const local = self.local;
const key = local.isolate.initStringHandle(name);
const handle = v8.v8__Object__Get(self.handle, self.local.handle, key) orelse {
return error.JsException;
};
return .{
.local = local,
.handle = handle,
};
}
pub fn persist(self: *const Function) !Global {
return self._persist(true);
}
pub fn temp(self: *const Function) !Temp {
return self._persist(false);
}
fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Global else Temp) {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.global_functions.append(ctx.arena, global);
} else {
try ctx.global_functions_temp.put(ctx.arena, global.data_ptr, global);
}
return .{ .handle = global };
}
pub fn tempWithThis(self: *const Function, value: anytype) !Temp {
const with_this = try self.withThis(value);
return with_this.temp();
}
pub fn persistWithThis(self: *const Function, value: anytype) !Global {
const with_this = try self.withThis(value);
return with_this.persist();
}
pub const Temp = G(0);
pub const Global = G(1);
fn G(comptime discriminator: u8) type {
return struct {
handle: v8.Global,
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
const Self = @This();
pub fn deinit(self: *Self) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Self, l: *const js.Local) Function {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
pub fn isEqual(self: *const Self, other: Function) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
};
}

View File

@@ -0,0 +1,36 @@
// 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 js = @import("js.zig");
const v8 = js.v8;
const HandleScope = @This();
handle: v8.HandleScope,
// V8 takes an address of the value that's passed in, so it needs to be stable.
// We can't create the v8.HandleScope here, pass it to v8 and then return the
// value, as v8 will then have taken the address of the function-scopped (and no
// longer valid) local.
pub fn init(self: *HandleScope, isolate: js.Isolate) void {
v8.v8__HandleScope__CONSTRUCT(&self.handle, isolate.handle);
}
pub fn deinit(self: *HandleScope) void {
v8.v8__HandleScope__DESTRUCT(&self.handle);
}

View File

@@ -20,63 +20,79 @@ const std = @import("std");
const js = @import("js.zig");
const v8 = js.v8;
const Context = @import("Context.zig");
const TaggedOpaque = @import("TaggedOpaque.zig");
const Allocator = std.mem.Allocator;
const 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();
pub const RemoteObject = v8.RemoteObject;
unique_id: i64,
isolate: *v8.Isolate,
handle: *v8.Inspector,
client: *v8.InspectorClientImpl,
default_context: ?v8.Global,
session: ?Session,
isolate: v8.Isolate,
inner: *v8.Inspector,
session: v8.InspectorSession,
pub fn init(allocator: Allocator, isolate: *v8.Isolate) !*Inspector {
const self = try allocator.create(Inspector);
errdefer allocator.destroy(self);
// We expect allocator to be an arena
pub fn init(allocator: Allocator, isolate: v8.Isolate, ctx: anytype) !Inspector {
const ContextT = @TypeOf(ctx);
const InspectorContainer = switch (@typeInfo(ContextT)) {
.@"struct" => ContextT,
.pointer => |ptr| ptr.child,
.void => NoopInspector,
else => @compileError("invalid context type"),
self.* = .{
.unique_id = 1,
.session = null,
.isolate = isolate,
.client = undefined,
.handle = undefined,
.default_context = null,
};
// If necessary, turn a void context into something we can safely ptrCast
const safe_context: *anyopaque = if (ContextT == void) @ptrCast(@constCast(&{})) else ctx;
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);
const channel = v8.InspectorChannel.init(
safe_context,
InspectorContainer.onInspectorResponse,
InspectorContainer.onInspectorEvent,
InspectorContainer.onRunMessageLoopOnPause,
InspectorContainer.onQuitMessageLoopOnPause,
isolate,
);
self.handle = v8.v8_inspector__Inspector__Create(isolate, self.client).?;
errdefer v8.v8_inspector__Inspector__DELETE(self.handle);
const client = v8.InspectorClient.init();
const inner = try allocator.create(v8.Inspector);
v8.Inspector.init(inner, client, channel, isolate);
return .{ .inner = inner, .isolate = isolate, .session = inner.connect() };
return self;
}
pub fn deinit(self: *const Inspector) void {
self.session.deinit();
self.inner.deinit();
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);
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.HandleScope.init(&temp_scope, isolate);
defer temp_scope.deinit();
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
@@ -88,75 +104,356 @@ pub fn send(self: *const Inspector, msg: []const u8) void {
// {isDefault: boolean, type: 'default'|'isolated'|'worker', frameId: string}
// - is_default_context: Whether the execution context is default, should match the auxData
pub fn contextCreated(
self: *const Inspector,
context: *const Context,
self: *Inspector,
local: *const js.Local,
name: []const u8,
origin: []const u8,
aux_data: ?[]const u8,
aux_data: []const u8,
is_default_context: bool,
) void {
self.inner.contextCreated(context.v8_context, name, origin, aux_data, is_default_context);
}
// 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 PersistedObject and if not
// we'll create it and track it for cleanup when the context ends.
pub fn getRemoteObject(
self: *const Inspector,
context: *Context,
group: []const u8,
value: anytype,
) !RemoteObject {
const js_value = try context.zigValueToJs(value, .{});
// We do not want to expose this as a parameter for now
const generate_preview = false;
return self.session.wrapObject(
context.isolate,
context.v8_context,
js_value,
group,
generate_preview,
v8.v8_inspector__Inspector__ContextCreated(
self.handle,
name.ptr,
name.len,
origin.ptr,
origin.len,
aux_data.ptr,
aux_data.len,
CONTEXT_GROUP_ID,
local.handle,
);
}
// Gets a value by object ID regardless of which context it is in.
// Our TaggedAnyOpaque stores the "resolved" ptr value (the most specific _type,
// e.g. we store the ptr to the Div not the EventTarget). But, this is asking for
// the pointer to the Node, so we need to use the same resolution mechanism which
// is used when we're calling a function to turn the Div into a Node, which is
// what Context.typeTaggedAnyOpaque does.
pub fn getNodePtr(self: *const Inspector, allocator: Allocator, object_id: []const u8) !*anyopaque {
const unwrapped = try self.session.unwrapObject(allocator, object_id);
// The values context and groupId are not used here
const js_val = unwrapped.value;
if (js_val.isObject() == false) {
return error.ObjectIdIsNotANode;
if (is_default_context) {
self.default_context = local.ctx.handle;
}
const Node = @import("../webapi/Node.zig");
return Context.typeTaggedAnyOpaque(*Node, js_val.castTo(v8.Object)) catch {
return error.ObjectIdIsNotANode;
};
}
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 {}
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 {
var hs: v8.HandleScope = undefined;
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate);
defer v8.v8__HandleScope__DESTRUCT(&hs);
v8.v8_inspector__Inspector__ResetContextGroup(self.handle, CONTEXT_GROUP_ID);
}
pub const RemoteObject = struct {
handle: *v8.RemoteObject,
pub fn deinit(self: RemoteObject) void {
v8.v8_inspector__RemoteObject__DELETE(self.handle);
}
pub fn getType(self: RemoteObject, allocator: Allocator) ![]const u8 {
var ctype_: v8.CZigString = .{ .ptr = null, .len = 0 };
if (!v8.v8_inspector__RemoteObject__getType(self.handle, &allocator, &ctype_)) return error.V8AllocFailed;
return cZigStringToString(ctype_) orelse return error.InvalidType;
}
pub fn getSubtype(self: RemoteObject, allocator: Allocator) !?[]const u8 {
if (!v8.v8_inspector__RemoteObject__hasSubtype(self.handle)) return null;
var csubtype: v8.CZigString = .{ .ptr = null, .len = 0 };
if (!v8.v8_inspector__RemoteObject__getSubtype(self.handle, &allocator, &csubtype)) return error.V8AllocFailed;
return cZigStringToString(csubtype);
}
pub fn getClassName(self: RemoteObject, allocator: Allocator) !?[]const u8 {
if (!v8.v8_inspector__RemoteObject__hasClassName(self.handle)) return null;
var cclass_name: v8.CZigString = .{ .ptr = null, .len = 0 };
if (!v8.v8_inspector__RemoteObject__getClassName(self.handle, &allocator, &cclass_name)) return error.V8AllocFailed;
return cZigStringToString(cclass_name);
}
pub fn getDescription(self: RemoteObject, allocator: Allocator) !?[]const u8 {
if (!v8.v8_inspector__RemoteObject__hasDescription(self.handle)) return null;
var description: v8.CZigString = .{ .ptr = null, .len = 0 };
if (!v8.v8_inspector__RemoteObject__getDescription(self.handle, &allocator, &description)) return error.V8AllocFailed;
return cZigStringToString(description);
}
pub fn getObjectId(self: RemoteObject, allocator: Allocator) !?[]const u8 {
if (!v8.v8_inspector__RemoteObject__hasObjectId(self.handle)) return null;
var cobject_id: v8.CZigString = .{ .ptr = null, .len = 0 };
if (!v8.v8_inspector__RemoteObject__getObjectId(self.handle, &allocator, &cobject_id)) return error.V8AllocFailed;
return cZigStringToString(cobject_id);
}
};
pub fn getTaggedAnyOpaque(value: v8.Value) ?*js.TaggedAnyOpaque {
if (value.isObject() == false) {
// 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,
// 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 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(
self: Session,
isolate: *v8.Isolate,
ctx: *const v8.Context,
val: *const v8.Value,
grpname: []const u8,
generatepreview: bool,
) !RemoteObject {
const remote_object = v8.v8_inspector__Session__wrapObject(
self.handle,
isolate,
ctx,
val,
grpname.ptr,
grpname.len,
generatepreview,
).?;
return .{ .handle = remote_object };
}
fn unwrapObject(
self: Session,
allocator: Allocator,
object_id: []const u8,
) !UnwrappedObject {
const in_object_id = v8.CZigString{
.ptr = object_id.ptr,
.len = object_id.len,
};
var out_error: v8.CZigString = .{ .ptr = null, .len = 0 };
var out_value_handle: ?*v8.Value = null;
var out_context_handle: ?*v8.Context = null;
var out_object_group: v8.CZigString = .{ .ptr = null, .len = 0 };
const result = v8.v8_inspector__Session__unwrapObject(
self.handle,
&allocator,
&out_error,
in_object_id,
&out_value_handle,
&out_context_handle,
&out_object_group,
);
if (!result) {
const error_str = cZigStringToString(out_error) orelse return error.UnwrapFailed;
std.log.err("unwrapObject failed: {s}", .{error_str});
return error.UnwrapFailed;
}
return .{
.value = out_value_handle.?,
.context = out_context_handle.?,
.object_group = cZigStringToString(out_object_group),
};
}
};
const UnwrappedObject = struct {
value: *const v8.Value,
context: *const v8.Context,
object_group: ?[]const u8,
};
pub fn getTaggedOpaque(value: *const v8.Value) ?*TaggedOpaque {
if (!v8.v8__Value__IsObject(value)) {
return null;
}
const obj = value.castTo(v8.Object);
if (obj.internalFieldCount() == 0) {
const internal_field_count = v8.v8__Object__InternalFieldCount(value);
if (internal_field_count == 0) {
return null;
}
const external_data = obj.getInternalField(0).castTo(v8.External).get().?;
const external_value = v8.v8__Object__GetInternalField(value, 0).?;
const external_data = v8.v8__External__Value(external_value).?;
return @ptrCast(@alignCast(external_data));
}
fn cZigStringToString(s: v8.CZigString) ?[]const u8 {
if (s.ptr == null) return null;
return s.ptr[0..s.len];
}
// C export functions for Inspector callbacks
pub export fn v8_inspector__Client__IMPL__generateUniqueId(
_: *v8.InspectorClientImpl,
data: *anyopaque,
) callconv(.c) i64 {
const inspector: *Inspector = @ptrCast(@alignCast(data));
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,
context_group_id: c_int,
) callconv(.c) void {
_ = data;
_ = context_group_id;
}
pub export fn v8_inspector__Client__IMPL__quitMessageLoopOnPause(
_: *v8.InspectorClientImpl,
data: *anyopaque,
) callconv(.c) void {
_ = data;
}
pub export fn v8_inspector__Client__IMPL__runIfWaitingForDebugger(
_: *v8.InspectorClientImpl,
_: *anyopaque,
_: c_int,
) callconv(.c) void {
// TODO
}
pub export fn v8_inspector__Client__IMPL__consoleAPIMessage(
_: *v8.InspectorClientImpl,
_: *anyopaque,
_: c_int,
_: v8.MessageErrorLevel,
_: *v8.StringView,
_: *v8.StringView,
_: c_uint,
_: c_uint,
_: *v8.StackTrace,
) callconv(.c) void {}
pub export fn v8_inspector__Client__IMPL__ensureDefaultContextInGroup(
_: *v8.InspectorClientImpl,
data: *anyopaque,
) callconv(.c) ?*const v8.Context {
const inspector: *Inspector = @ptrCast(@alignCast(data));
const global_handle = inspector.default_context orelse return null;
return v8.v8__Global__Get(&global_handle, inspector.isolate);
}
pub export fn v8_inspector__Channel__IMPL__sendResponse(
_: *v8.InspectorChannelImpl,
data: *anyopaque,
call_id: c_int,
msg: [*c]u8,
length: usize,
) callconv(.c) void {
const session: *Session = @ptrCast(@alignCast(data));
session.onResp(session.ctx, @intCast(call_id), msg[0..length]);
}
pub export fn v8_inspector__Channel__IMPL__sendNotification(
_: *v8.InspectorChannelImpl,
data: *anyopaque,
msg: [*c]u8,
length: usize,
) callconv(.c) void {
const session: *Session = @ptrCast(@alignCast(data));
session.onNotif(session.ctx, msg[0..length]);
}
pub export fn v8_inspector__Channel__IMPL__flushProtocolNotifications(
_: *v8.InspectorChannelImpl,
_: *anyopaque,
) callconv(.c) void {
// TODO
}

View File

@@ -19,22 +19,17 @@
const std = @import("std");
const js = @import("js.zig");
// This only exists so that we know whether a function wants the opaque
// JS argument (js.Object), or if it wants the receiver as an opaque
// value.
// js.Object is normally used when a method wants an opaque JS object
// that it'll pass into a callback.
// This is used when the function wants to do advanced manipulation
// of the v8.Object bound to the instance. For example, postAttach is an
// example of using This.
const v8 = js.v8;
const This = @This();
obj: js.Object,
const Integer = @This();
pub fn setIndex(self: This, index: u32, value: anytype, opts: js.Object.SetOpts) !void {
return self.obj.setIndex(index, value, opts);
}
pub fn set(self: This, key: []const u8, value: anytype, opts: js.Object.SetOpts) !void {
return self.obj.set(key, value, opts);
handle: *const v8.Integer,
pub fn init(isolate: *v8.Isolate, value: anytype) Integer {
const handle = switch (@TypeOf(value)) {
i8, i16, i32 => v8.v8__Integer__New(isolate, value).?,
u8, u16, u32 => v8.v8__Integer__NewFromUnsigned(isolate, value).?,
else => |T| @compileError("cannot create v8::Integer from: " ++ @typeName(T)),
};
return .{ .handle = handle };
}

128
src/browser/js/Isolate.zig Normal file
View File

@@ -0,0 +1,128 @@
// 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 js = @import("js.zig");
const v8 = js.v8;
const Isolate = @This();
handle: *v8.Isolate,
pub fn init(params: *v8.CreateParams) Isolate {
return .{
.handle = v8.v8__Isolate__New(params).?,
};
}
pub fn deinit(self: Isolate) void {
v8.v8__Isolate__Dispose(self.handle);
}
pub fn enter(self: Isolate) void {
v8.v8__Isolate__Enter(self.handle);
}
pub fn exit(self: Isolate) void {
v8.v8__Isolate__Exit(self.handle);
}
pub fn performMicrotasksCheckpoint(self: Isolate) void {
v8.v8__Isolate__PerformMicrotaskCheckpoint(self.handle);
}
pub fn enqueueMicrotask(self: Isolate, callback: anytype, data: anytype) void {
v8.v8__Isolate__EnqueueMicrotask(self.handle, callback, data);
}
pub fn enqueueMicrotaskFunc(self: Isolate, function: js.Function) void {
v8.v8__Isolate__EnqueueMicrotaskFunc(self.handle, function.handle);
}
pub fn lowMemoryNotification(self: Isolate) void {
v8.v8__Isolate__LowMemoryNotification(self.handle);
}
pub const MemoryPressureLevel = enum(u32) {
none = v8.kNone,
moderate = v8.kModerate,
critical = v8.kCritical,
};
pub fn memoryPressureNotification(self: Isolate, level: MemoryPressureLevel) void {
v8.v8__Isolate__MemoryPressureNotification(self.handle, @intFromEnum(level));
}
pub fn notifyContextDisposed(self: Isolate) void {
_ = v8.v8__Isolate__ContextDisposedNotification(self.handle);
}
pub fn getHeapStatistics(self: Isolate) v8.HeapStatistics {
var res: v8.HeapStatistics = undefined;
v8.v8__Isolate__GetHeapStatistics(self.handle, &res);
return res;
}
pub fn throwException(self: Isolate, value: *const v8.Value) *const v8.Value {
return v8.v8__Isolate__ThrowException(self.handle, value).?;
}
pub fn initStringHandle(self: Isolate, str: []const u8) *const v8.String {
return v8.v8__String__NewFromUtf8(self.handle, str.ptr, v8.kNormal, @as(c_int, @intCast(str.len))).?;
}
pub fn createError(self: Isolate, msg: []const u8) *const v8.Value {
const message = self.initStringHandle(msg);
return v8.v8__Exception__Error(message).?;
}
pub fn createTypeError(self: Isolate, msg: []const u8) *const v8.Value {
const message = self.initStringHandle(msg);
return v8.v8__Exception__TypeError(message).?;
}
pub fn initNull(self: Isolate) *const v8.Value {
return v8.v8__Null(self.handle).?;
}
pub fn initUndefined(self: Isolate) *const v8.Value {
return v8.v8__Undefined(self.handle).?;
}
pub fn initFalse(self: Isolate) *const v8.Value {
return v8.v8__False(self.handle).?;
}
pub fn initTrue(self: Isolate) *const v8.Value {
return v8.v8__True(self.handle).?;
}
pub fn initInteger(self: Isolate, val: anytype) js.Integer {
return js.Integer.init(self.handle, val);
}
pub fn initBigInt(self: Isolate, val: anytype) js.BigInt {
return js.BigInt.init(self.handle, val);
}
pub fn initNumber(self: Isolate, val: anytype) js.Number {
return js.Number.init(self.handle, val);
}
pub fn createExternal(self: Isolate, val: *anyopaque) *const v8.External {
return v8.v8__External__New(self.handle, val).?;
}

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

File diff suppressed because it is too large Load Diff

137
src/browser/js/Module.zig Normal file
View File

@@ -0,0 +1,137 @@
// 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 js = @import("js.zig");
const v8 = js.v8;
const Module = @This();
local: *const js.Local,
handle: *const v8.Module,
pub const Status = enum(u32) {
kUninstantiated = v8.kUninstantiated,
kInstantiating = v8.kInstantiating,
kInstantiated = v8.kInstantiated,
kEvaluating = v8.kEvaluating,
kEvaluated = v8.kEvaluated,
kErrored = v8.kErrored,
};
pub fn getStatus(self: Module) Status {
return @enumFromInt(v8.v8__Module__GetStatus(self.handle));
}
pub fn getException(self: Module) js.Value {
return .{
.local = self.local,
.handle = v8.v8__Module__GetException(self.handle).?,
};
}
pub fn getModuleRequests(self: Module) Requests {
return .{
.context_handle = self.local.handle,
.handle = v8.v8__Module__GetModuleRequests(self.handle).?,
};
}
pub fn instantiate(self: Module, cb: v8.ResolveModuleCallback) !bool {
var out: v8.MaybeBool = undefined;
v8.v8__Module__InstantiateModule(self.handle, self.local.handle, cb, &out);
if (out.has_value) {
return out.value;
}
return error.JsException;
}
pub fn evaluate(self: Module) !js.Value {
const res = v8.v8__Module__Evaluate(self.handle, self.local.handle) orelse return error.JsException;
if (self.getStatus() == .kErrored) {
return error.JsException;
}
return .{
.local = self.local,
.handle = res,
};
}
pub fn getIdentityHash(self: Module) u32 {
return @bitCast(v8.v8__Module__GetIdentityHash(self.handle));
}
pub fn getModuleNamespace(self: Module) js.Value {
return .{
.local = self.local,
.handle = v8.v8__Module__GetModuleNamespace(self.handle).?,
};
}
pub fn getScriptId(self: Module) u32 {
return @intCast(v8.v8__Module__ScriptId(self.handle));
}
pub fn persist(self: Module) !Global {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.global_modules.append(ctx.arena, global);
return .{ .handle = global };
}
pub const Global = struct {
handle: v8.Global,
pub fn deinit(self: *Global) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Global, l: *const js.Local) Module {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
pub fn isEqual(self: *const Global, other: Module) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
};
const Requests = struct {
handle: *const v8.FixedArray,
context_handle: *const v8.Context,
pub fn len(self: Requests) usize {
return @intCast(v8.v8__FixedArray__Length(self.handle));
}
pub fn get(self: Requests, idx: usize) Request {
return .{ .handle = v8.v8__FixedArray__Get(self.handle, self.context_handle, @intCast(idx)).? };
}
};
const Request = struct {
handle: *const v8.ModuleRequest,
pub fn specifier(self: Request, local: *const js.Local) js.String {
return .{ .local = local, .handle = v8.v8__ModuleRequest__GetSpecifier(self.handle).? };
}
};

31
src/browser/js/Number.zig Normal file
View File

@@ -0,0 +1,31 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("js.zig");
const v8 = js.v8;
const Number = @This();
handle: *const v8.Number,
pub fn init(isolate: *v8.Isolate, value: anytype) Number {
const handle = v8.v8__Number__New(isolate, value).?;
return .{ .handle = 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,103 +22,102 @@ const v8 = js.v8;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Context = @import("Context.zig");
const PersistentObject = v8.Persistent(v8.Object);
const Allocator = std.mem.Allocator;
const Object = @This();
js_obj: v8.Object,
context: *js.Context,
pub fn getId(self: Object) u32 {
return self.js_obj.getIdentityHash();
local: *const js.Local,
handle: *const v8.Object,
pub fn has(self: Object, key: anytype) bool {
const ctx = self.local.ctx;
const key_handle = if (@TypeOf(key) == *const v8.String) key else ctx.isolate.initStringHandle(key);
var out: v8.MaybeBool = undefined;
v8.v8__Object__Has(self.handle, self.local.handle, key_handle, &out);
if (out.has_value) {
return out.value;
}
return false;
}
pub const SetOpts = packed struct(u32) {
READ_ONLY: bool = false,
DONT_ENUM: bool = false,
DONT_DELETE: bool = false,
_: u29 = 0,
};
pub fn setIndex(self: Object, index: u32, value: anytype, opts: SetOpts) !void {
@setEvalBranchQuota(10000);
const key = switch (index) {
inline 0...20 => |i| std.fmt.comptimePrint("{d}", .{i}),
else => try std.fmt.allocPrint(self.context.arena, "{d}", .{index}),
pub fn get(self: Object, key: anytype) !js.Value {
const ctx = self.local.ctx;
const key_handle = if (@TypeOf(key) == *const v8.String) key else ctx.isolate.initStringHandle(key);
const js_val_handle = v8.v8__Object__Get(self.handle, self.local.handle, key_handle) orelse return error.JsException;
return .{
.local = self.local,
.handle = js_val_handle,
};
return self.set(key, value, opts);
}
pub fn set(self: Object, key: []const u8, value: anytype, opts: SetOpts) error{ FailedToSet, OutOfMemory }!void {
const context = self.context;
pub fn set(self: Object, key: anytype, value: anytype, comptime opts: js.Caller.CallOpts) !bool {
const ctx = self.local.ctx;
const js_key = v8.String.initUtf8(context.isolate, key);
const js_value = try context.zigValueToJs(value);
const js_value = try self.local.zigValueToJs(value, opts);
const key_handle = if (@TypeOf(key) == *const v8.String) key else ctx.isolate.initStringHandle(key);
const res = self.js_obj.defineOwnProperty(context.v8_context, js_key.toName(), js_value, @bitCast(opts)) orelse false;
if (!res) {
return error.FailedToSet;
var out: v8.MaybeBool = undefined;
v8.v8__Object__Set(self.handle, self.local.handle, key_handle, js_value.handle, &out);
return out.has_value;
}
pub fn defineOwnProperty(self: Object, name: []const u8, value: js.Value, attr: v8.PropertyAttribute) ?bool {
const ctx = self.local.ctx;
const name_handle = ctx.isolate.initStringHandle(name);
var out: v8.MaybeBool = undefined;
v8.v8__Object__DefineOwnProperty(self.handle, self.local.handle, @ptrCast(name_handle), value.handle, attr, &out);
if (out.has_value) {
return out.value;
} else {
return null;
}
}
pub fn get(self: Object, key: []const u8) !js.Value {
const context = self.context;
const js_key = v8.String.initUtf8(context.isolate, key);
const js_val = try self.js_obj.getValue(context.v8_context, js_key);
return context.createValue(js_val);
}
pub fn isTruthy(self: Object) bool {
const js_value = self.js_obj.toValue();
return js_value.toBool(self.context.isolate);
}
pub fn toString(self: Object) ![]const u8 {
const js_value = self.js_obj.toValue();
return self.context.valueToString(js_value, .{});
pub fn toValue(self: Object) js.Value {
return .{
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
pub fn format(self: Object, writer: *std.Io.Writer) !void {
if (comptime IS_DEBUG) {
return self.context.debugValue(self.js_obj.toValue(), writer);
return self.local.ctx.debugValue(self.toValue(), writer);
}
const str = self.toString() catch return error.WriteFailed;
return writer.writeAll(str);
}
pub fn toJson(self: Object, allocator: Allocator) ![]u8 {
const json_string = try v8.Json.stringify(self.context.v8_context, self.js_obj.toValue(), null);
const str = try self.context.jsStringToZig(json_string, .{ .allocator = allocator });
return str;
}
pub fn persist(self: Object) !Global {
var ctx = self.local.ctx;
pub fn persist(self: Object) !Object {
var context = self.context;
const js_obj = self.js_obj;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
const persisted = PersistentObject.init(context.isolate, js_obj);
try context.js_object_list.append(context.arena, persisted);
try ctx.global_objects.append(ctx.arena, global);
return .{
.context = context,
.js_obj = persisted.castToObject(),
};
return .{ .handle = global };
}
pub fn getFunction(self: Object, name: []const u8) !?js.Function {
if (self.isNullOrUndefined()) {
return null;
}
const context = self.context;
const local = self.local;
const js_name = v8.String.initUtf8(context.isolate, name);
const js_name = local.isolate.initStringHandle(name);
const js_val_handle = v8.v8__Object__Get(self.handle, local.handle, js_name) orelse return error.JsException;
const js_value = try self.js_obj.getValue(context.v8_context, js_name.toName());
if (!js_value.isFunction()) {
if (v8.v8__Value__IsFunction(js_val_handle) == false) {
return null;
}
return try context.createFunction(js_value);
return .{
.local = local,
.handle = @ptrCast(js_val_handle),
};
}
pub fn callMethod(self: Object, comptime T: type, method_name: []const u8, args: anytype) !T {
@@ -126,41 +125,66 @@ pub fn callMethod(self: Object, comptime T: type, method_name: []const u8, args:
return func.callWithThis(T, self, args);
}
pub fn isNull(self: Object) bool {
return self.js_obj.toValue().isNull();
}
pub fn isUndefined(self: Object) bool {
return self.js_obj.toValue().isUndefined();
}
pub fn isNullOrUndefined(self: Object) bool {
return self.js_obj.toValue().isNullOrUndefined();
return v8.v8__Value__IsNullOrUndefined(@ptrCast(self.handle));
}
pub fn getOwnPropertyNames(self: Object) js.Array {
const handle = v8.v8__Object__GetOwnPropertyNames(self.handle, self.local.handle).?;
return .{
.local = self.local,
.handle = handle,
};
}
pub fn getPropertyNames(self: Object) js.Array {
const handle = v8.v8__Object__GetPropertyNames(self.handle, self.local.handle).?;
return .{
.local = self.local,
.handle = handle,
};
}
pub fn nameIterator(self: Object) NameIterator {
const context = self.context;
const js_obj = self.js_obj;
const array = js_obj.getPropertyNames(context.v8_context);
const count = array.length();
const handle = v8.v8__Object__GetPropertyNames(self.handle, self.local.handle).?;
const count = v8.v8__Array__Length(handle);
return .{
.local = self.local,
.handle = handle,
.count = count,
.context = context,
.js_obj = array.castTo(v8.Object),
};
}
pub fn toZig(self: Object, comptime T: type) !T {
return self.context.jsValueToZig(T, self.js_obj.toValue());
const js_value = js.Value{ .local = self.local, .handle = @ptrCast(self.handle) };
return self.local.jsValueToZig(T, js_value);
}
pub const Global = struct {
handle: v8.Global,
pub fn deinit(self: *Global) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Global, l: *const js.Local) Object {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
pub fn isEqual(self: *const Global, other: Object) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
};
pub const NameIterator = struct {
count: u32,
idx: u32 = 0,
js_obj: v8.Object,
context: *const Context,
local: *const js.Local,
handle: *const v8.Array,
pub fn next(self: *NameIterator) !?[]const u8 {
const idx = self.idx;
@@ -169,8 +193,8 @@ pub const NameIterator = struct {
}
self.idx += 1;
const context = self.context;
const js_val = try self.js_obj.getAtIndex(context.v8_context, idx);
return try context.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

@@ -20,20 +20,22 @@ const js = @import("js.zig");
const v8 = js.v8;
const Platform = @This();
inner: v8.Platform,
handle: *v8.Platform,
pub fn init() !Platform {
if (v8.initV8ICU() == false) {
if (v8.v8__V8__InitializeICU() == false) {
return error.FailedToInitializeICU;
}
const platform = v8.Platform.initDefault(0, true);
v8.initV8Platform(platform);
v8.initV8();
return .{ .inner = platform };
// 0 - threadpool size, 0 == let v8 decide
// 1 - idle_task_support, 1 == enabled
const handle = v8.v8__Platform__NewDefaultPlatform(0, 1).?;
v8.v8__V8__InitializePlatform(handle);
v8.v8__V8__Initialize();
return .{ .handle = handle };
}
pub fn deinit(self: Platform) void {
_ = v8.deinitV8();
v8.deinitV8Platform();
self.inner.deinit();
_ = v8.v8__V8__Dispose();
v8.v8__V8__DisposePlatform();
v8.v8__Platform__DELETE(self.handle);
}

View File

@@ -0,0 +1,95 @@
// 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 js = @import("js.zig");
const v8 = js.v8;
const Promise = @This();
local: *const js.Local,
handle: *const v8.Promise,
pub fn toObject(self: Promise) js.Object {
return .{
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
pub fn toValue(self: Promise) js.Value {
return .{
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
pub fn thenAndCatch(self: Promise, on_fulfilled: js.Function, on_rejected: js.Function) !Promise {
if (v8.v8__Promise__Then2(self.handle, self.local.handle, on_fulfilled.handle, on_rejected.handle)) |handle| {
return .{
.local = self.local,
.handle = handle,
};
}
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);
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 Temp = G(0);
pub const Global = G(1);
fn G(comptime discriminator: u8) type {
return struct {
handle: v8.Global,
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
const Self = @This();
pub fn deinit(self: *Self) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Self, l: *const js.Local) Promise {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
};
}

View File

@@ -0,0 +1,41 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const js = @import("js.zig");
const v8 = js.v8;
const PromiseRejection = @This();
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

@@ -0,0 +1,99 @@
// 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 js = @import("js.zig");
const v8 = js.v8;
const log = @import("../../log.zig");
const PromiseResolver = @This();
local: *const js.Local,
handle: *const v8.PromiseResolver,
pub fn init(local: *const js.Local) PromiseResolver {
return .{
.local = local,
.handle = v8.v8__Promise__Resolver__New(local.handle).?,
};
}
pub fn promise(self: PromiseResolver) js.Promise {
return .{
.local = self.local,
.handle = v8.v8__Promise__Resolver__GetPromise(self.handle).?,
};
}
pub fn resolve(self: PromiseResolver, comptime source: []const u8, value: anytype) void {
self._resolve(value) catch |err| {
log.err(.bug, "resolve", .{ .source = source, .err = err, .persistent = false });
};
}
fn _resolve(self: PromiseResolver, value: anytype) !void {
const local = self.local;
const js_val = try local.zigValueToJs(value, .{});
var out: v8.MaybeBool = undefined;
v8.v8__Promise__Resolver__Resolve(self.handle, self.local.handle, js_val.handle, &out);
if (!out.has_value or !out.value) {
return error.FailedToResolvePromise;
}
local.runMicrotasks();
}
pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype) void {
self._reject(value) catch |err| {
log.err(.bug, "reject", .{ .source = source, .err = err, .persistent = false });
};
}
fn _reject(self: PromiseResolver, value: anytype) !void {
const local = self.local;
const js_val = try local.zigValueToJs(value, .{});
var out: v8.MaybeBool = undefined;
v8.v8__Promise__Resolver__Reject(self.handle, local.handle, js_val.handle, &out);
if (!out.has_value or !out.value) {
return error.FailedToRejectPromise;
}
local.runMicrotasks();
}
pub fn persist(self: PromiseResolver) !Global {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.global_promise_resolvers.append(ctx.arena, global);
return .{ .handle = global };
}
pub const Global = struct {
handle: v8.Global,
pub fn deinit(self: *Global) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Global, l: *const js.Local) PromiseResolver {
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,8 +19,8 @@
const std = @import("std");
const builtin = @import("builtin");
const log = @import("../log.zig");
const milliTimestamp = @import("../datetime.zig").milliTimestamp;
const log = @import("../../log.zig");
const milliTimestamp = @import("../../datetime.zig").milliTimestamp;
const IS_DEBUG = builtin.mode == .Debug;
@@ -47,9 +47,15 @@ pub fn init(allocator: std.mem.Allocator) Scheduler {
};
}
pub fn deinit(self: *Scheduler) void {
finalizeTasks(&self.low_priority);
finalizeTasks(&self.high_priority);
}
const AddOpts = struct {
name: []const u8 = "",
low_priority: bool = false,
finalizer: ?Finalizer = null,
};
pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts: AddOpts) !void {
if (comptime IS_DEBUG) {
@@ -63,6 +69,7 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts
.callback = cb,
.sequence = seq,
.name = opts.name,
.finalizer = opts.finalizer,
.run_at = milliTimestamp(.monotonic) + run_in_ms,
});
}
@@ -72,6 +79,11 @@ pub fn run(self: *Scheduler) !?u64 {
return self.runQueue(&self.high_priority);
}
pub fn hasReadyTasks(self: *Scheduler) bool {
const now = milliTimestamp(.monotonic);
return queueuHasReadyTask(&self.low_priority, now) or queueuHasReadyTask(&self.high_priority, now);
}
fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
if (queue.count() == 0) {
return null;
@@ -95,7 +107,9 @@ fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
if (repeat_in_ms) |ms| {
// Task cannot be repeated immediately, and they should know that
std.debug.assert(ms != 0);
if (comptime IS_DEBUG) {
std.debug.assert(ms != 0);
}
task.run_at = now + ms;
try self.low_priority.add(task);
}
@@ -103,12 +117,28 @@ fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
return null;
}
fn queueuHasReadyTask(queue: *Queue, now: u64) bool {
const task = queue.peek() orelse return false;
return task.run_at <= now;
}
fn finalizeTasks(queue: *Queue) void {
var it = queue.iterator();
while (it.next()) |t| {
if (t.finalizer) |func| {
func(t.ctx);
}
}
}
const Task = struct {
run_at: u64,
sequence: u64,
ctx: *anyopaque,
name: []const u8,
callback: Callback,
finalizer: ?Finalizer,
};
const Callback = *const fn (ctx: *anyopaque) anyerror!?u32;
const Finalizer = *const fn (ctx: *anyopaque) void;

View File

@@ -22,7 +22,6 @@ const bridge = @import("bridge.zig");
const log = @import("../../log.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
const Window = @import("../webapi/Window.zig");
const v8 = js.v8;
const JsApis = bridge.JsApis;
@@ -53,14 +52,14 @@ startup_data: v8.StartupData,
external_references: [countExternalReferences()]isize,
// Track whether this snapshot owns its data (was created in-process)
// If false, the data points into embedded_snapshot_blob and should not be freed
// If false, the data points into embedded_snapshot_blob and will not be freed
owns_data: bool = false,
pub fn load(allocator: Allocator) !Snapshot {
pub fn load() !Snapshot {
if (loadEmbedded()) |snapshot| {
return snapshot;
}
return create(allocator);
return create();
}
fn loadEmbedded() ?Snapshot {
@@ -75,7 +74,7 @@ fn loadEmbedded() ?Snapshot {
const blob = embedded_snapshot_blob[@sizeOf(usize)..];
const startup_data = v8.StartupData{ .data = blob.ptr, .raw_size = @intCast(blob.len) };
if (!v8.SnapshotCreator.startupDataIsValid(startup_data)) {
if (!v8.v8__StartupData__IsValid(startup_data)) {
return null;
}
@@ -87,10 +86,11 @@ fn loadEmbedded() ?Snapshot {
};
}
pub fn deinit(self: Snapshot, allocator: Allocator) void {
pub fn deinit(self: Snapshot) void {
// Only free if we own the data (was created in-process)
if (self.owns_data) {
allocator.free(self.startup_data.data[0..@intCast(self.startup_data.raw_size)]);
// V8 allocated this with `new char[]`, so we need to use the C++ delete[] operator
v8.v8__StartupData__DELETE(self.startup_data.data);
}
}
@@ -105,39 +105,39 @@ pub fn write(self: Snapshot, writer: *std.Io.Writer) !void {
pub fn fromEmbedded(self: Snapshot) bool {
// if the snapshot comes from the embedFile, then it'll be flagged as not
// owneing (aka, not needing to free) the data.
// owning (aka, not needing to free) the data.
return self.owns_data == false;
}
fn isValid(self: Snapshot) bool {
return v8.SnapshotCreator.startupDataIsValid(self.startup_data);
return v8.v8__StartupData__IsValid(self.startup_data);
}
pub fn create(allocator: Allocator) !Snapshot {
pub fn create() !Snapshot {
var external_references = collectExternalReferences();
var params = v8.initCreateParams();
params.array_buffer_allocator = v8.createDefaultArrayBufferAllocator();
defer v8.destroyArrayBufferAllocator(params.array_buffer_allocator.?);
var params: v8.CreateParams = undefined;
v8.v8__Isolate__CreateParams__CONSTRUCT(&params);
params.array_buffer_allocator = v8.v8__ArrayBuffer__Allocator__NewDefaultAllocator();
defer v8.v8__ArrayBuffer__Allocator__DELETE(params.array_buffer_allocator.?);
params.external_references = @ptrCast(&external_references);
var snapshot_creator: v8.SnapshotCreator = undefined;
v8.SnapshotCreator.init(&snapshot_creator, &params);
defer snapshot_creator.deinit();
const snapshot_creator = v8.v8__SnapshotCreator__CREATE(&params);
defer v8.v8__SnapshotCreator__DESTRUCT(snapshot_creator);
var data_start: usize = 0;
const isolate = snapshot_creator.getIsolate();
const isolate = v8.v8__SnapshotCreator__getIsolate(snapshot_creator).?;
{
// CreateBlob, which we'll call once everything is setup, MUST NOT
// be called from an active HandleScope. Hence we have this scope to
// clean it up before we call CreateBlob
var handle_scope: v8.HandleScope = undefined;
v8.HandleScope.init(&handle_scope, isolate);
defer handle_scope.deinit();
v8.v8__HandleScope__CONSTRUCT(&handle_scope, isolate);
defer v8.v8__HandleScope__DESTRUCT(&handle_scope);
// Create templates (constructors only) FIRST
var templates: [JsApis.len]v8.FunctionTemplate = undefined;
var templates: [JsApis.len]*v8.FunctionTemplate = undefined;
inline for (JsApis, 0..) |JsApi, i| {
@setEvalBranchQuota(10_000);
templates[i] = generateConstructor(JsApi, isolate);
@@ -148,30 +148,21 @@ pub fn create(allocator: Allocator) !Snapshot {
// This must come before attachClass so inheritance is set up first
inline for (JsApis, 0..) |JsApi, i| {
if (comptime protoIndexLookup(JsApi)) |proto_index| {
templates[i].inherit(templates[proto_index]);
v8.v8__FunctionTemplate__Inherit(templates[i], templates[proto_index]);
}
}
// Set up the global template to inherit from Window's template
// This way the global object gets all Window properties through inheritance
const js_global = v8.FunctionTemplate.initDefault(isolate);
js_global.setClassName(v8.String.initUtf8(isolate, "Window"));
// Find Window in JsApis by name (avoids circular import)
const window_index = comptime bridge.JsApiLookup.getId(Window.JsApi);
js_global.inherit(templates[window_index]);
const global_template = js_global.getInstanceTemplate();
const context = v8.Context.init(isolate, global_template, null);
context.enter();
defer context.exit();
const context = v8.v8__Context__New(isolate, null, null);
v8.v8__Context__Enter(context);
defer v8.v8__Context__Exit(context);
// Add templates to context snapshot
var last_data_index: usize = 0;
inline for (JsApis, 0..) |_, i| {
@setEvalBranchQuota(10_000);
const data_index = snapshot_creator.addDataWithContext(context, @ptrCast(templates[i].handle));
const data_index = v8.v8__SnapshotCreator__AddData(snapshot_creator, @ptrCast(templates[i]));
if (i == 0) {
data_start = data_index;
last_data_index = data_index;
@@ -189,16 +180,18 @@ pub fn create(allocator: Allocator) !Snapshot {
}
// Realize all templates by getting their functions and attaching to global
const global_obj = context.getGlobal();
const global_obj = v8.v8__Context__Global(context);
inline for (JsApis, 0..) |JsApi, i| {
const func = templates[i].getFunction(context);
const func = v8.v8__FunctionTemplate__GetFunction(templates[i], context);
// Attach to global if it has a name
if (@hasDecl(JsApi.Meta, "name")) {
if (@hasDecl(JsApi.Meta, "constructor_alias")) {
const v8_class_name = v8.String.initUtf8(isolate, JsApi.Meta.constructor_alias);
_ = global_obj.setValue(context, v8_class_name, func);
const alias = JsApi.Meta.constructor_alias;
const v8_class_name = v8.v8__String__NewFromUtf8(isolate, alias.ptr, v8.kNormal, @intCast(alias.len));
var maybe_result: v8.MaybeBool = undefined;
v8.v8__Object__Set(global_obj, context, v8_class_name, func, &maybe_result);
// @TODO: This is wrong. This name should be registered with the
// illegalConstructorCallback. I.e. new Image() is OK, but
@@ -206,11 +199,15 @@ pub fn create(allocator: Allocator) !Snapshot {
// But we _have_ to register the name, i.e. HTMLImageElement
// has to be registered so, for now, instead of creating another
// template, we just hook it into the constructor.
const illegal_class_name = v8.String.initUtf8(isolate, JsApi.Meta.name);
_ = global_obj.setValue(context, illegal_class_name, func);
const name = JsApi.Meta.name;
const illegal_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
var maybe_result2: v8.MaybeBool = undefined;
v8.v8__Object__Set(global_obj, context, illegal_class_name, func, &maybe_result2);
} else {
const v8_class_name = v8.String.initUtf8(isolate, JsApi.Meta.name);
_ = global_obj.setValue(context, v8_class_name, func);
const name = JsApi.Meta.name;
const v8_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
var maybe_result: v8.MaybeBool = undefined;
v8.v8__Object__Set(global_obj, context, v8_class_name, func, &maybe_result);
}
}
}
@@ -218,8 +215,10 @@ pub fn create(allocator: Allocator) !Snapshot {
{
// If we want to overwrite the built-in console, we have to
// delete the built-in one.
const console_key = v8.String.initUtf8(isolate, "console");
if (global_obj.deleteValue(context, console_key) == false) {
const console_key = v8.v8__String__NewFromUtf8(isolate, "console", v8.kNormal, 7);
var maybe_deleted: v8.MaybeBool = undefined;
v8.v8__Object__Delete(global_obj, context, console_key, &maybe_deleted);
if (maybe_deleted.value == false) {
return error.ConsoleDeleteError;
}
}
@@ -229,39 +228,59 @@ pub fn create(allocator: Allocator) !Snapshot {
// TODO: see if newer V8 engines have a way around this.
inline for (JsApis, 0..) |JsApi, i| {
if (comptime protoIndexLookup(JsApi)) |proto_index| {
const proto_obj = templates[proto_index].getFunction(context).toObject();
const self_obj = templates[i].getFunction(context).toObject();
_ = self_obj.setPrototype(context, proto_obj);
const proto_func = v8.v8__FunctionTemplate__GetFunction(templates[proto_index], context);
const proto_obj: *const v8.Object = @ptrCast(proto_func);
const self_func = v8.v8__FunctionTemplate__GetFunction(templates[i], context);
const self_obj: *const v8.Object = @ptrCast(self_func);
var maybe_result: v8.MaybeBool = undefined;
v8.v8__Object__SetPrototype(self_obj, context, proto_obj, &maybe_result);
}
}
{
// Custom exception
// TODO: this is an horrible hack, I can't figure out how to do this cleanly.
const code = v8.String.initUtf8(isolate, "DOMException.prototype.__proto__ = Error.prototype");
_ = try (try v8.Script.compile(context, code, null)).run(context);
const code_str = "DOMException.prototype.__proto__ = Error.prototype";
const code = v8.v8__String__NewFromUtf8(isolate, code_str.ptr, v8.kNormal, @intCast(code_str.len));
const script = v8.v8__Script__Compile(context, code, null) orelse return error.ScriptCompileFailed;
_ = v8.v8__Script__Run(script, context) orelse return error.ScriptRunFailed;
}
snapshot_creator.setDefaultContext(context);
v8.v8__SnapshotCreator__setDefaultContext(snapshot_creator, context);
}
const blob = snapshot_creator.createBlob(v8.FunctionCodeHandling.kKeep);
const owned = try allocator.dupe(u8, blob.data[0..@intCast(blob.raw_size)]);
const blob = v8.v8__SnapshotCreator__createBlob(snapshot_creator, v8.kKeep);
return .{
.owns_data = true,
.data_start = data_start,
.external_references = external_references,
.startup_data = .{ .data = owned.ptr, .raw_size = @intCast(owned.len) },
.startup_data = blob,
};
}
// 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)
@@ -284,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) {
@@ -296,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
}
@@ -306,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));
@@ -331,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;
@@ -352,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;
}
@@ -361,7 +418,7 @@ fn collectExternalReferences() [countExternalReferences()]isize {
// via `new ClassName()` - but they could, for example, be created in
// Zig and returned from a function call, which is why we need the
// FunctionTemplate.
fn generateConstructor(comptime JsApi: type, isolate: v8.Isolate) v8.FunctionTemplate {
fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionTemplate {
const callback = blk: {
if (@hasDecl(JsApi, "constructor")) {
break :blk JsApi.constructor.func;
@@ -371,19 +428,25 @@ fn generateConstructor(comptime JsApi: type, isolate: v8.Isolate) v8.FunctionTem
break :blk illegalConstructorCallback;
};
const template = v8.FunctionTemplate.initCallback(isolate, callback);
const template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?);
if (!@hasDecl(JsApi.Meta, "empty_with_no_proto")) {
template.getInstanceTemplate().setInternalFieldCount(1);
const instance_template = v8.v8__FunctionTemplate__InstanceTemplate(template);
v8.v8__ObjectTemplate__SetInternalFieldCount(instance_template, 1);
}
const class_name = v8.String.initUtf8(isolate, if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi));
template.setClassName(class_name);
const name_str = if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi);
const class_name = v8.v8__String__NewFromUtf8(isolate, name_str.ptr, v8.kNormal, @intCast(name_str.len));
v8.v8__FunctionTemplate__SetClassName(template, class_name);
return template;
}
// Attaches JsApi members to the prototype template (normal case)
fn attachClass(comptime JsApi: type, isolate: v8.Isolate, template: v8.FunctionTemplate) void {
const target = template.getPrototypeTemplate();
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.FunctionTemplate) void {
const target = v8.v8__FunctionTemplate__PrototypeTemplate(template);
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;
const value = @field(JsApi, name);
@@ -391,60 +454,92 @@ fn attachClass(comptime JsApi: type, isolate: v8.Isolate, template: v8.FunctionT
switch (definition) {
bridge.Accessor => {
const js_name = v8.String.initUtf8(isolate, name).toName();
const getter_callback = v8.FunctionTemplate.initCallback(isolate, value.getter);
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.getter }).?);
if (value.setter == null) {
if (value.static) {
template.setAccessorGetter(js_name, getter_callback);
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback);
} else {
target.setAccessorGetter(js_name, getter_callback);
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(target, js_name, getter_callback);
}
} else {
std.debug.assert(value.static == false);
const setter_callback = v8.FunctionTemplate.initCallback(isolate, value.setter);
target.setAccessorGetterAndSetter(js_name, getter_callback, setter_callback);
if (comptime IS_DEBUG) {
std.debug.assert(value.static == false);
}
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 = v8.FunctionTemplate.initCallback(isolate, value.func);
const js_name: v8.Name = v8.String.initUtf8(isolate, name).toName();
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) {
template.set(js_name, function_template, v8.PropertyAttribute.None);
v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
} else {
target.set(js_name, function_template, v8.PropertyAttribute.None);
v8.v8__Template__Set(@ptrCast(target), js_name, @ptrCast(function_template), v8.None);
}
},
bridge.Indexed => {
const configuration = v8.IndexedPropertyHandlerConfiguration{
var configuration: v8.IndexedPropertyHandlerConfiguration = .{
.getter = value.getter,
.setter = null,
.query = null,
.deleter = null,
.enumerator = null,
.definer = null,
.descriptor = null,
.data = null,
.flags = 0,
};
target.setIndexedProperty(configuration, null);
v8.v8__ObjectTemplate__SetIndexedHandler(instance, &configuration);
},
bridge.NamedIndexed => {
var configuration: v8.NamedPropertyHandlerConfiguration = .{
.getter = value.getter,
.setter = value.setter,
.query = null,
.deleter = value.deleter,
.enumerator = null,
.definer = null,
.descriptor = null,
.data = null,
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
};
v8.v8__ObjectTemplate__SetNamedHandler(instance, &configuration);
has_named_index_getter = true;
},
bridge.NamedIndexed => template.getInstanceTemplate().setNamedProperty(.{
.getter = value.getter,
.setter = value.setter,
.deleter = value.deleter,
.flags = v8.PropertyHandlerFlags.OnlyInterceptStrings | v8.PropertyHandlerFlags.NonMasking,
}, null),
bridge.Iterator => {
const function_template = v8.FunctionTemplate.initCallback(isolate, value.func);
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?);
const js_name = if (value.async)
v8.Symbol.getAsyncIterator(isolate).toName()
v8.v8__Symbol__GetAsyncIterator(isolate)
else
v8.Symbol.getIterator(isolate).toName();
target.set(js_name, function_template, v8.PropertyAttribute.None);
v8.v8__Symbol__GetIterator(isolate);
v8.v8__Template__Set(@ptrCast(target), js_name, @ptrCast(function_template), v8.None);
},
bridge.Property => {
const js_value = switch (value) {
.int => |v| js.simpleZigValueToJs(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));
const js_name = v8.String.initUtf8(isolate, name).toName();
// apply it both to the type itself
template.set(js_name, js_value, v8.PropertyAttribute.ReadOnly + v8.PropertyAttribute.DontDelete);
// and to instances of the type
target.set(js_name, js_value, v8.PropertyAttribute.ReadOnly + v8.PropertyAttribute.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 => {},
@@ -452,9 +547,31 @@ fn attachClass(comptime JsApi: type, isolate: v8.Isolate, template: v8.FunctionT
}
if (@hasDecl(JsApi.Meta, "htmldda")) {
const instance_template = template.getInstanceTemplate();
instance_template.markAsUndetectable();
instance_template.setCallAsFunctionHandler(JsApi.Meta.callable.func);
v8.v8__ObjectTemplate__MarkAsUndetectable(instance);
v8.v8__ObjectTemplate__SetCallAsFunctionHandler(instance, JsApi.Meta.callable.func);
}
if (@hasDecl(JsApi.Meta, "name")) {
const js_name = v8.v8__Symbol__GetToStringTag(isolate);
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);
}
}
}
@@ -472,10 +589,15 @@ fn protoIndexLookup(comptime JsApi: type) ?bridge.JsApiLookup.BackingInt {
}
// Shared illegal constructor callback for types without explicit constructors
fn illegalConstructorCallback(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void {
const info = v8.FunctionCallbackInfo.initFromV8(raw_info);
const iso = info.getIsolate();
fn illegalConstructorCallback(raw_info: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(raw_info);
log.warn(.js, "Illegal constructor call", .{});
const js_exception = iso.throwException(js._createException(iso, "Illegal Constructor"));
info.getReturnValue().set(js_exception);
const message = v8.v8__String__NewFromUtf8(isolate, "Illegal Constructor", v8.kNormal, 19);
const js_exception = v8.v8__Exception__TypeError(message);
_ = v8.v8__Isolate__ThrowException(isolate, js_exception);
var return_value: v8.ReturnValue = undefined;
v8.v8__FunctionCallbackInfo__GetReturnValue(raw_info, &return_value);
v8.v8__ReturnValue__Set(return_value, js_exception);
}

111
src/browser/js/String.zig Normal file
View File

@@ -0,0 +1,111 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("js.zig");
const SSO = @import("../../string.zig").String;
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const v8 = js.v8;
const String = @This();
local: *const js.Local,
handle: *const v8.String,
pub fn toSlice(self: String) ![]u8 {
return self._toSlice(false, self.local.call_arena);
}
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;
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);
}
return buf;
}
pub fn toSSO(self: String, comptime global: bool) !(if (global) SSO.Global else SSO) {
if (comptime global) {
return .{ .str = try self.toSSOWithAlloc(self.local.ctx.arena) };
}
return self.toSSOWithAlloc(self.local.call_arena);
}
pub fn toSSOWithAlloc(self: String, allocator: Allocator) !SSO {
const handle = self.handle;
const isolate = self.local.isolate.handle;
const len: usize = @intCast(v8.v8__String__Utf8Length(handle, isolate));
if (len <= 12) {
var content: [12]u8 = undefined;
const n = v8.v8__String__WriteUtf8(handle, isolate, &content[0], content.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
if (comptime IS_DEBUG) {
std.debug.assert(n == len);
}
// Weird that we do this _after_, but we have to..I've seen weird issues
// in ReleaseMode where v8 won't write to content if it starts off zero
// initiated
@memset(content[len..], 0);
return .{ .len = @intCast(len), .payload = .{ .content = content } };
}
const buf = try allocator.alloc(u8, len);
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
if (comptime IS_DEBUG) {
std.debug.assert(n == len);
}
var prefix: [4]u8 = @splat(0);
@memcpy(&prefix, buf[0..4]);
return .{
.len = @intCast(len),
.payload = .{ .heap = .{
.prefix = prefix,
.ptr = buf.ptr,
} },
};
}
pub fn format(self: String, writer: *std.Io.Writer) !void {
const local = self.local;
const handle = self.handle;
const isolate = local.isolate.handle;
var small: [1024]u8 = undefined;
const len = v8.v8__String__Utf8Length(handle, isolate);
var buf = if (len < 1024) &small else local.call_arena.alloc(u8, @intCast(len)) catch return error.WriteFailed;
const n = v8.v8__String__WriteUtf8(handle, isolate, buf.ptr, buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
return writer.writeAll(buf[0..n]);
}

View File

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

View File

@@ -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,63 +20,118 @@ 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();
inner: v8.TryCatch,
context: *const js.Context,
handle: v8.TryCatch,
local: *const js.Local,
pub fn init(self: *TryCatch, context: *const js.Context) void {
self.context = context;
self.inner.init(context.isolate);
pub fn init(self: *TryCatch, l: *const js.Local) void {
self.local = l;
v8.v8__TryCatch__CONSTRUCT(&self.handle, l.isolate.handle);
}
pub fn hasCaught(self: TryCatch) bool {
return self.inner.hasCaught();
return v8.v8__TryCatch__HasCaught(&self.handle);
}
// the caller needs to deinit the string returned
pub fn exception(self: TryCatch, allocator: Allocator) !?[]const u8 {
const msg = self.inner.getException() orelse return null;
return try self.context.valueToString(msg, .{ .allocator = allocator });
}
// the caller needs to deinit the string returned
pub fn stack(self: TryCatch, allocator: Allocator) !?[]const u8 {
const context = self.context;
const s = self.inner.getStackTrace(context.v8_context) orelse return null;
return try context.valueToString(s, .{ .allocator = allocator });
}
// the caller needs to deinit the string returned
pub fn sourceLine(self: TryCatch, allocator: Allocator) !?[]const u8 {
const context = self.context;
const msg = self.inner.getMessage() orelse return null;
const sl = msg.getSourceLine(context.v8_context) orelse return null;
return try context.jsStringToZig(sl, .{ .allocator = allocator });
}
pub fn sourceLineNumber(self: TryCatch) ?u32 {
const context = self.context;
const msg = self.inner.getMessage() orelse return null;
return msg.getLineNumber(context.v8_context);
}
// a shorthand method to return either the entire stack message
// or just the exception message
// - in Debug mode return the stack if available
// - otherwise return the exception if available
// the caller needs to deinit the string returned
pub fn err(self: TryCatch, allocator: Allocator) !?[]const u8 {
if (comptime @import("builtin").mode == .Debug) {
if (try self.stack(allocator)) |msg| {
return msg;
}
pub fn rethrow(self: *TryCatch) void {
if (comptime IS_DEBUG) {
std.debug.assert(self.hasCaught());
}
return try self.exception(allocator);
_ = v8.v8__TryCatch__ReThrow(&self.handle);
}
pub fn caught(self: TryCatch, allocator: Allocator) ?Caught {
if (self.hasCaught() == false) {
return null;
}
const l = self.local;
const line: ?u32 = blk: {
const handle = v8.v8__TryCatch__Message(&self.handle) orelse return null;
const line = v8.v8__Message__GetLineNumber(handle, l.handle);
break :blk if (line < 0) null else @intCast(line);
};
const exception: ?[]const u8 = blk: {
const handle = v8.v8__TryCatch__Exception(&self.handle) orelse break :blk null;
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;
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 .{
.line = line,
.stack = stack,
.caught = true,
.exception = exception,
};
}
pub fn caughtOrError(self: TryCatch, allocator: Allocator, err: anyerror) Caught {
return self.caught(allocator) orelse .{
.caught = false,
.line = null,
.stack = null,
.exception = @errorName(err),
};
}
pub fn deinit(self: *TryCatch) void {
self.inner.deinit();
v8.v8__TryCatch__DESTRUCT(&self.handle);
}
pub const Caught = struct {
line: ?u32 = null,
caught: bool = false,
stack: ?[]const u8 = null,
exception: ?[]const u8 = null,
pub fn format(self: Caught, writer: *std.Io.Writer) !void {
const separator = @import("../../log.zig").separator();
try writer.print("{s}exception: {?s}", .{ separator, self.exception });
try writer.print("{s}stack: {?s}", .{ separator, self.stack });
try writer.print("{s}line: {?d}", .{ separator, self.line });
try writer.print("{s}caught: {any}", .{ separator, self.caught });
}
pub fn logFmt(self: Caught, comptime prefix: []const u8, writer: anytype) !void {
try writer.write(prefix ++ ".exception", self.exception orelse "???");
try writer.write(prefix ++ ".stack", self.stack orelse "na");
try writer.write(prefix ++ ".line", self.line);
try writer.write(prefix ++ ".caught", self.caught);
}
};

View File

@@ -18,75 +18,323 @@
const std = @import("std");
const js = @import("js.zig");
const SSO = @import("../../string.zig").String;
const v8 = js.v8;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Allocator = std.mem.Allocator;
const PersistentValue = v8.Persistent(v8.Value);
const Value = @This();
js_val: v8.Value,
context: *js.Context,
local: *const js.Local,
handle: *const v8.Value,
pub fn isObject(self: Value) bool {
return self.js_val.isObject();
return v8.v8__Value__IsObject(self.handle);
}
pub fn isString(self: Value) bool {
return self.js_val.isString();
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 {
return self.js_val.isArray();
return v8.v8__Value__IsArray(self.handle);
}
pub fn toString(self: Value, allocator: Allocator) ![]const u8 {
return self.context.valueToString(self.js_val, .{ .allocator = allocator });
pub fn isSymbol(self: Value) bool {
return v8.v8__Value__IsSymbol(self.handle);
}
pub fn fromJson(ctx: *js.Context, json: []const u8) !Value {
const json_string = v8.String.initUtf8(ctx.isolate, json);
const value = try v8.Json.parse(ctx.v8_context, json_string);
return Value{ .context = ctx, .js_val = value };
pub fn isFunction(self: Value) bool {
return v8.v8__Value__IsFunction(self.handle);
}
pub fn persist(self: Value) !Value {
const js_val = self.js_val;
var context = self.context;
pub fn isNull(self: Value) bool {
return v8.v8__Value__IsNull(self.handle);
}
const persisted = PersistentValue.init(context.isolate, js_val);
try context.js_value_list.append(context.arena, persisted);
pub fn isUndefined(self: Value) bool {
return v8.v8__Value__IsUndefined(self.handle);
}
return Value{ .context = context, .js_val = persisted.toValue() };
pub fn isNullOrUndefined(self: Value) bool {
return v8.v8__Value__IsNullOrUndefined(self.handle);
}
pub fn isNumber(self: Value) bool {
return v8.v8__Value__IsNumber(self.handle);
}
pub fn isNumberObject(self: Value) bool {
return v8.v8__Value__IsNumberObject(self.handle);
}
pub fn isInt32(self: Value) bool {
return v8.v8__Value__IsInt32(self.handle);
}
pub fn isUint32(self: Value) bool {
return v8.v8__Value__IsUint32(self.handle);
}
pub fn isBigInt(self: Value) bool {
return v8.v8__Value__IsBigInt(self.handle);
}
pub fn isBigIntObject(self: Value) bool {
return v8.v8__Value__IsBigIntObject(self.handle);
}
pub fn isBoolean(self: Value) bool {
return v8.v8__Value__IsBoolean(self.handle);
}
pub fn isBooleanObject(self: Value) bool {
return v8.v8__Value__IsBooleanObject(self.handle);
}
pub fn isTrue(self: Value) bool {
return v8.v8__Value__IsTrue(self.handle);
}
pub fn isFalse(self: Value) bool {
return v8.v8__Value__IsFalse(self.handle);
}
pub fn isTypedArray(self: Value) bool {
return v8.v8__Value__IsTypedArray(self.handle);
}
pub fn isArrayBufferView(self: Value) bool {
return v8.v8__Value__IsArrayBufferView(self.handle);
}
pub fn isArrayBuffer(self: Value) bool {
return v8.v8__Value__IsArrayBuffer(self.handle);
}
pub fn isUint8Array(self: Value) bool {
return v8.v8__Value__IsUint8Array(self.handle);
}
pub fn isUint8ClampedArray(self: Value) bool {
return v8.v8__Value__IsUint8ClampedArray(self.handle);
}
pub fn isInt8Array(self: Value) bool {
return v8.v8__Value__IsInt8Array(self.handle);
}
pub fn isUint16Array(self: Value) bool {
return v8.v8__Value__IsUint16Array(self.handle);
}
pub fn isInt16Array(self: Value) bool {
return v8.v8__Value__IsInt16Array(self.handle);
}
pub fn isUint32Array(self: Value) bool {
return v8.v8__Value__IsUint32Array(self.handle);
}
pub fn isInt32Array(self: Value) bool {
return v8.v8__Value__IsInt32Array(self.handle);
}
pub fn isBigUint64Array(self: Value) bool {
return v8.v8__Value__IsBigUint64Array(self.handle);
}
pub fn isBigInt64Array(self: Value) bool {
return v8.v8__Value__IsBigInt64Array(self.handle);
}
pub fn isPromise(self: Value) bool {
return v8.v8__Value__IsPromise(self.handle);
}
pub fn toBool(self: Value) bool {
return v8.v8__Value__BooleanValue(self.handle, self.local.isolate.handle);
}
pub fn typeOf(self: Value) js.String {
const str_handle = v8.v8__Value__TypeOf(self.handle, self.local.isolate.handle).?;
return js.String{ .local = self.local, .handle = str_handle };
}
pub fn toF32(self: Value) !f32 {
return @floatCast(try self.toF64());
}
pub fn toF64(self: Value) !f64 {
var maybe: v8.MaybeF64 = undefined;
v8.v8__Value__NumberValue(self.handle, self.local.handle, &maybe);
if (!maybe.has_value) {
return error.JsException;
}
return maybe.value;
}
pub fn toI32(self: Value) !i32 {
var maybe: v8.MaybeI32 = undefined;
v8.v8__Value__Int32Value(self.handle, self.local.handle, &maybe);
if (!maybe.has_value) {
return error.JsException;
}
return maybe.value;
}
pub fn toU32(self: Value) !u32 {
var maybe: v8.MaybeU32 = undefined;
v8.v8__Value__Uint32Value(self.handle, self.local.handle, &maybe);
if (!maybe.has_value) {
return error.JsException;
}
return maybe.value;
}
pub fn toPromise(self: Value) js.Promise {
if (comptime IS_DEBUG) {
std.debug.assert(self.isPromise());
}
return .{
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
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 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 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 {
return self._persist(true);
}
pub fn temp(self: Value) !Temp {
return self._persist(false);
}
fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Global else Temp) {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.global_values.append(ctx.arena, global);
} else {
try ctx.global_values_temp.put(ctx.arena, global.data_ptr, global);
}
return .{ .handle = global };
}
pub fn toZig(self: Value, comptime T: type) !T {
return self.local.jsValueToZig(T, self);
}
pub fn toObject(self: Value) js.Object {
if (comptime IS_DEBUG) {
std.debug.assert(self.isObject());
}
return .{
.context = self.context,
.js_obj = self.js_val.castTo(v8.Object),
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
pub fn toArray(self: Value) js.Array {
if (comptime IS_DEBUG) {
std.debug.assert(self.isArray());
}
return .{
.context = self.context,
.js_arr = self.js_val.castTo(v8.Array),
.local = self.local,
.handle = @ptrCast(self.handle),
};
}
// pub const Value = struct {
// value: v8.Value,
// context: *const Context,
pub fn toBigInt(self: Value) js.BigInt {
if (comptime IS_DEBUG) {
std.debug.assert(self.isBigInt());
}
// // the caller needs to deinit the string returned
// pub fn toString(self: Value, allocator: Allocator) ![]const u8 {
// return self.context.valueToString(self.value, .{ .allocator = allocator });
// }
return .{
.handle = @ptrCast(self.handle),
};
}
// pub fn fromJson(ctx: *Context, json: []const u8) !Value {
// const json_string = v8.String.initUtf8(ctx.isolate, json);
// const value = try v8.Json.parse(ctx.v8_context, json_string);
// return Value{ .context = ctx, .value = value };
// }
// };
pub fn format(self: Value, writer: *std.Io.Writer) !void {
if (comptime IS_DEBUG) {
return self.local.debugValue(self, writer);
}
const js_str = self.toString() catch return error.WriteFailed;
return js_str.format(writer);
}
pub const Temp = G(0);
pub const Global = G(1);
fn G(comptime discriminator: u8) type {
return struct {
handle: v8.Global,
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
const Self = @This();
pub fn deinit(self: *Self) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Self, l: *const js.Local) Value {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
pub fn isEqual(self: *const Self, other: Value) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
};
}

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>
@@ -18,11 +18,16 @@
const std = @import("std");
const js = @import("js.zig");
const lp = @import("lightpanda");
const log = @import("../../log.zig");
const Page = @import("../Page.zig");
const v8 = js.v8;
const Caller = @import("Caller.zig");
const Context = @import("Context.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
pub fn Builder(comptime T: type) type {
return struct {
@@ -57,16 +62,29 @@ 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");
}
pub fn prototypeChain() [prototypeChainLength(T)]js.PrototypeChainEntry {
var entries: [prototypeChainLength(T)]js.PrototypeChainEntry = undefined;
const PrototypeChainEntry = @import("TaggedOpaque.zig").PrototypeChainEntry;
pub fn prototypeChain() [prototypeChainLength(T)]PrototypeChainEntry {
var entries: [prototypeChainLength(T)]PrototypeChainEntry = undefined;
entries[0] = .{ .offset = 0, .index = JsApiLookup.getId(T.JsApi) };
@@ -85,11 +103,39 @@ pub fn Builder(comptime T: type) type {
}
return entries;
}
pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool) void) Finalizer {
return .{
.from_zig = struct {
fn wrap(ptr: *anyopaque) void {
func(@ptrCast(@alignCast(ptr)), true);
}
}.wrap,
.from_v8 = struct {
fn wrap(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void {
const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?;
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);
}
}
}.wrap,
};
}
};
}
pub const Constructor = struct {
func: *const fn (?*const v8.C_FunctionCallbackInfo) callconv(.c) void,
func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
const Opts = struct {
dom_exception: bool = false,
@@ -97,12 +143,13 @@ pub const Constructor = struct {
fn init(comptime T: type, comptime func: anytype, comptime opts: Opts) Constructor {
return .{ .func = struct {
fn wrap(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void {
const info = v8.FunctionCallbackInfo.initFromV8(raw_info);
var caller = Caller.init(info);
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
caller.constructor(T, func, info, .{
caller.constructor(T, func, handle.?, .{
.dom_exception = opts.dom_exception,
});
}
@@ -112,7 +159,8 @@ pub const Constructor = struct {
pub const Function = struct {
static: bool,
func: *const fn (?*const v8.C_FunctionCallbackInfo) callconv(.c) void,
arity: usize,
func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
const Opts = struct {
static: bool = false,
@@ -124,20 +172,22 @@ 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(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void {
const info = v8.FunctionCallbackInfo.initFromV8(raw_info);
var caller = Caller.init(info);
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
if (comptime opts.static) {
caller.function(T, func, info, .{
caller.function(T, func, handle.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
} else {
caller.method(T, func, info, .{
caller.method(T, func, handle.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
@@ -147,18 +197,34 @@ 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 {
static: bool = false,
getter: ?*const fn (?*const v8.C_FunctionCallbackInfo) callconv(.c) void = null,
setter: ?*const fn (?*const v8.C_FunctionCallbackInfo) callconv(.c) void = null,
getter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
setter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
const Opts = struct {
static: bool = false,
cache: ?[]const u8 = null, // @ZIGDOM
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 {
@@ -168,29 +234,39 @@ pub const Accessor = struct {
if (@typeInfo(@TypeOf(getter)) != .null) {
accessor.getter = struct {
fn wrap(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void {
const info = v8.FunctionCallbackInfo.initFromV8(raw_info);
var caller = Caller.init(info);
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
caller.method(T, getter, info, .{
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
if (comptime opts.static) {
caller.function(T, getter, handle.?, .{
.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.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
}
}
}.wrap;
}
if (@typeInfo(@TypeOf(setter)) != .null) {
accessor.setter = struct {
fn wrap(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void {
const info = v8.FunctionCallbackInfo.initFromV8(raw_info);
std.debug.assert(info.length() == 1);
var caller = Caller.init(info);
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
caller.method(T, setter, info, .{
caller.method(T, setter, handle.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
@@ -203,7 +279,7 @@ pub const Accessor = struct {
};
pub const Indexed = struct {
getter: *const fn (idx: u32, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8,
getter: *const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8,
const Opts = struct {
as_typed_array: bool = false,
@@ -212,11 +288,13 @@ pub const Indexed = struct {
fn init(comptime T: type, comptime getter: anytype, comptime opts: Opts) Indexed {
return .{ .getter = struct {
fn wrap(idx: u32, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 {
const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
var caller = Caller.init(info);
fn wrap(idx: u32, 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();
return caller.getIndex(T, getter, idx, info, .{
return caller.getIndex(T, getter, idx, handle.?, .{
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
@@ -226,9 +304,9 @@ pub const Indexed = struct {
};
pub const NamedIndexed = struct {
getter: *const fn (c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8,
setter: ?*const fn (c_name: ?*const v8.C_Name, c_value: ?*const v8.C_Value, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 = null,
deleter: ?*const fn (c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 = null,
getter: *const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8,
setter: ?*const fn (c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 = null,
deleter: ?*const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 = null,
const Opts = struct {
as_typed_array: bool = false,
@@ -237,11 +315,13 @@ pub const NamedIndexed = struct {
fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, comptime opts: Opts) NamedIndexed {
const getter_fn = struct {
fn wrap(c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 {
const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
var caller = Caller.init(info);
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();
return caller.getNamedIndex(T, getter, .{ .handle = c_name.? }, info, .{
return caller.getNamedIndex(T, getter, c_name.?, handle.?, .{
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
@@ -249,12 +329,13 @@ pub const NamedIndexed = struct {
}.wrap;
const setter_fn = if (@typeInfo(@TypeOf(setter)) == .null) null else struct {
fn wrap(c_name: ?*const v8.C_Name, c_value: ?*const v8.C_Value, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 {
const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
var caller = Caller.init(info);
fn wrap(c_name: ?*const v8.Name, c_value: ?*const v8.Value, 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();
return caller.setNamedIndex(T, setter, .{ .handle = c_name.? }, .{ .handle = c_value.? }, info, .{
return caller.setNamedIndex(T, setter, c_name.?, c_value.?, handle.?, .{
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
@@ -262,12 +343,13 @@ pub const NamedIndexed = struct {
}.wrap;
const deleter_fn = if (@typeInfo(@TypeOf(deleter)) == .null) null else struct {
fn wrap(c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 {
const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
var caller = Caller.init(info);
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();
return caller.deleteNamedIndex(T, deleter, .{ .handle = c_name.? }, info, .{
return caller.deleteNamedIndex(T, deleter, c_name.?, handle.?, .{
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
@@ -283,7 +365,7 @@ pub const NamedIndexed = struct {
};
pub const Iterator = struct {
func: *const fn (?*const v8.C_FunctionCallbackInfo) callconv(.c) void,
func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
async: bool,
const Opts = struct {
@@ -296,8 +378,8 @@ pub const Iterator = struct {
return .{
.async = opts.async,
.func = struct {
fn wrap(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void {
const info = v8.FunctionCallbackInfo.initFromV8(raw_info);
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const info = Caller.FunctionCallbackInfo{ .handle = handle.? };
info.getReturnValue().set(info.getThis());
}
}.wrap,
@@ -307,11 +389,12 @@ pub const Iterator = struct {
return .{
.async = opts.async,
.func = struct {
fn wrap(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void {
const info = v8.FunctionCallbackInfo.initFromV8(raw_info);
var caller = Caller.init(info);
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
caller.method(T, struct_or_func, info, .{});
caller.method(T, struct_or_func, handle.?, .{});
}
}.wrap,
};
@@ -319,7 +402,7 @@ pub const Iterator = struct {
};
pub const Callable = struct {
func: *const fn (?*const v8.C_FunctionCallbackInfo) callconv(.c) void,
func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
const Opts = struct {
null_as_undefined: bool = false,
@@ -327,11 +410,13 @@ pub const Callable = struct {
fn init(comptime T: type, comptime func: anytype, comptime opts: Opts) Callable {
return .{ .func = struct {
fn wrap(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void {
const info = v8.FunctionCallbackInfo.initFromV8(raw_info);
var caller = Caller.init(info);
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
caller.method(T, func, info, .{
caller.method(T, func, handle.?, .{
.null_as_undefined = opts.null_as_undefined,
});
}
@@ -339,10 +424,195 @@ 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 {
// The finalizer wrapper when called fro Zig. This is only called on
// Context.deinit
from_zig: *const fn (ctx: *anyopaque) void,
// The finalizer wrapper when called from V8. This may never be called
// (hence why we fallback to calling in Context.denit). If it is called,
// it is only ever called after we SetWeak on the Global.
from_v8: *const fn (?*const v8.WeakCallbackInfo) callconv(.c) void,
};
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);
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;
};
const page = local.ctx.page;
const document = page.document;
if (document.getElementById(property, page)) |el| {
const js_val = local.zigValueToJs(el, .{}) catch return 0;
var pc = Caller.PropertyCallbackInfo{ .handle = handle.? };
pc.getReturnValue().set(js_val);
return 1;
}
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", {} },
.{ "litHtmlPolyfillSupport", {} },
.{ "litElementHydrateSupport", {} },
.{ "litElementPolyfillSupport", {} },
.{ "reactiveElementVersions", {} },
.{ "recaptcha", {} },
.{ "grecaptcha", {} },
.{ "___grecaptcha_cfg", {} },
.{ "__recaptcha_api", {} },
.{ "__google_recaptcha_client", {} },
.{ "CLOSURE_FLAGS", {} },
.{ "__REACT_DEVTOOLS_GLOBAL_HOOK__", {} },
.{ "ApplePaySession", {} },
});
if (!ignored.has(property)) {
const key = std.fmt.bufPrint(&local.ctx.page.buf, "Window:{s}", .{property}) catch return 0;
logUnknownProperty(local, key) catch return 0;
}
}
// not intercepted
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;
@@ -529,16 +799,22 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/element/Html.zig"),
@import("../webapi/element/html/IFrame.zig"),
@import("../webapi/element/html/Anchor.zig"),
@import("../webapi/element/html/Area.zig"),
@import("../webapi/element/html/Audio.zig"),
@import("../webapi/element/html/Base.zig"),
@import("../webapi/element/html/Body.zig"),
@import("../webapi/element/html/BR.zig"),
@import("../webapi/element/html/Button.zig"),
@import("../webapi/element/html/Canvas.zig"),
@import("../webapi/element/html/Custom.zig"),
@import("../webapi/element/html/Data.zig"),
@import("../webapi/element/html/DataList.zig"),
@import("../webapi/element/html/Dialog.zig"),
@import("../webapi/element/html/Directory.zig"),
@import("../webapi/element/html/Div.zig"),
@import("../webapi/element/html/Embed.zig"),
@import("../webapi/element/html/FieldSet.zig"),
@import("../webapi/element/html/Font.zig"),
@import("../webapi/element/html/Form.zig"),
@import("../webapi/element/html/Generic.zig"),
@import("../webapi/element/html/Head.zig"),
@@ -547,20 +823,43 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/element/html/Html.zig"),
@import("../webapi/element/html/Image.zig"),
@import("../webapi/element/html/Input.zig"),
@import("../webapi/element/html/Label.zig"),
@import("../webapi/element/html/Legend.zig"),
@import("../webapi/element/html/LI.zig"),
@import("../webapi/element/html/Link.zig"),
@import("../webapi/element/html/Map.zig"),
@import("../webapi/element/html/Media.zig"),
@import("../webapi/element/html/Meta.zig"),
@import("../webapi/element/html/Meter.zig"),
@import("../webapi/element/html/Mod.zig"),
@import("../webapi/element/html/Object.zig"),
@import("../webapi/element/html/OL.zig"),
@import("../webapi/element/html/OptGroup.zig"),
@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"),
@import("../webapi/element/html/Quote.zig"),
@import("../webapi/element/html/Script.zig"),
@import("../webapi/element/html/Select.zig"),
@import("../webapi/element/html/Slot.zig"),
@import("../webapi/element/html/Source.zig"),
@import("../webapi/element/html/Span.zig"),
@import("../webapi/element/html/Style.zig"),
@import("../webapi/element/html/Table.zig"),
@import("../webapi/element/html/TableCaption.zig"),
@import("../webapi/element/html/TableCell.zig"),
@import("../webapi/element/html/TableCol.zig"),
@import("../webapi/element/html/TableRow.zig"),
@import("../webapi/element/html/TableSection.zig"),
@import("../webapi/element/html/Template.zig"),
@import("../webapi/element/html/TextArea.zig"),
@import("../webapi/element/html/Time.zig"),
@import("../webapi/element/html/Title.zig"),
@import("../webapi/element/html/Track.zig"),
@import("../webapi/element/html/Video.zig"),
@import("../webapi/element/html/UL.zig"),
@import("../webapi/element/html/Unknown.zig"),
@@ -579,7 +878,9 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/event/PopStateEvent.zig"),
@import("../webapi/event/UIEvent.zig"),
@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"),
@@ -604,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"),
@@ -612,9 +914,14 @@ 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"),
@import("../webapi/navigation/NavigationHistoryEntry.zig"),
@import("../webapi/navigation/NavigationActivation.zig"),
@import("../webapi/canvas/CanvasRenderingContext2D.zig"),
@import("../webapi/canvas/WebGLRenderingContext.zig"),
@import("../webapi/SubtleCrypto.zig"),
@import("../webapi/Selection.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>
@@ -17,25 +17,34 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
pub const v8 = @import("v8");
pub const v8 = @import("v8").c;
const log = @import("../../log.zig");
const string = @import("../../string.zig");
pub const Env = @import("Env.zig");
pub const bridge = @import("bridge.zig");
pub const ExecutionWorld = @import("ExecutionWorld.zig");
pub const Caller = @import("Caller.zig");
pub const Context = @import("Context.zig");
pub const Local = @import("Local.zig");
pub const Inspector = @import("Inspector.zig");
pub const Snapshot = @import("Snapshot.zig");
pub const Platform = @import("Platform.zig");
pub const Isolate = @import("Isolate.zig");
pub const HandleScope = @import("HandleScope.zig");
// TODO: Is "This" really necessary?
pub const This = @import("This.zig");
pub const Value = @import("Value.zig");
pub const Array = @import("Array.zig");
pub const String = @import("String.zig");
pub const Object = @import("Object.zig");
pub const TryCatch = @import("TryCatch.zig");
pub const Function = @import("Function.zig");
pub const Promise = @import("Promise.zig");
pub const Module = @import("Module.zig");
pub const BigInt = @import("BigInt.zig");
pub const Number = @import("Number.zig");
pub const Integer = @import("Integer.zig");
pub const PromiseResolver = @import("PromiseResolver.zig");
pub const PromiseRejection = @import("PromiseRejection.zig");
const Allocator = std.mem.Allocator;
@@ -68,246 +77,43 @@ pub const ArrayBuffer = struct {
}
};
pub const PromiseResolver = struct {
context: *Context,
resolver: v8.PromiseResolver,
pub fn promise(self: PromiseResolver) Promise {
return self.resolver.getPromise();
}
pub fn resolve(self: PromiseResolver, comptime source: []const u8, value: anytype) void {
self._resolve(value) catch |err| {
log.err(.bug, "resolve", .{ .source = source, .err = err, .persistent = false });
};
}
fn _resolve(self: PromiseResolver, value: anytype) !void {
const context = self.context;
const js_value = try context.zigValueToJs(value, .{});
if (self.resolver.resolve(context.v8_context, js_value) == null) {
return error.FailedToResolvePromise;
}
self.context.runMicrotasks();
}
pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype) void {
self._reject(value) catch |err| {
log.err(.bug, "reject", .{ .source = source, .err = err, .persistent = false });
};
}
fn _reject(self: PromiseResolver, value: anytype) !void {
const context = self.context;
const js_value = try context.zigValueToJs(value);
if (self.resolver.reject(context.v8_context, js_value) == null) {
return error.FailedToRejectPromise;
}
self.context.runMicrotasks();
}
};
pub const PersistentPromiseResolver = struct {
context: *Context,
resolver: v8.Persistent(v8.PromiseResolver),
pub fn deinit(self: *PersistentPromiseResolver) void {
self.resolver.deinit();
}
pub fn promise(self: PersistentPromiseResolver) Promise {
return self.resolver.castToPromiseResolver().getPromise();
}
pub fn resolve(self: PersistentPromiseResolver, comptime source: []const u8, value: anytype) void {
self._resolve(value) catch |err| {
log.err(.bug, "resolve", .{ .source = source, .err = err, .persistent = true });
};
}
fn _resolve(self: PersistentPromiseResolver, value: anytype) !void {
const context = self.context;
const js_value = try context.zigValueToJs(value, .{});
defer context.runMicrotasks();
if (self.resolver.castToPromiseResolver().resolve(context.v8_context, js_value) == null) {
return error.FailedToResolvePromise;
}
}
pub fn reject(self: PersistentPromiseResolver, comptime source: []const u8, value: anytype) void {
self._reject(value) catch |err| {
log.err(.bug, "reject", .{ .source = source, .err = err, .persistent = true });
};
}
fn _reject(self: PersistentPromiseResolver, value: anytype) !void {
const context = self.context;
const js_value = try context.zigValueToJs(value, .{});
defer context.runMicrotasks();
// resolver.reject will return null if the promise isn't pending
if (self.resolver.castToPromiseResolver().reject(context.v8_context, js_value) == null) {
return error.FailedToRejectPromise;
}
}
};
pub const Promise = v8.Promise;
// When doing jsValueToZig, string ([]const u8) are managed by the
// call_arena. That means that if the API wants to persist the string
// (which is relatively common), it needs to dupe it again.
// If the parameter is an Env.String rather than a []const u8, then
// the page's arena will be used (rather than the call arena).
pub const String = struct {
string: []const u8,
};
pub const Exception = struct {
inner: v8.Value,
context: *const Context,
// the caller needs to deinit the string returned
pub fn exception(self: Exception, allocator: Allocator) ![]const u8 {
return self.context.valueToString(self.inner, .{ .allocator = allocator });
}
local: *const Local,
handle: *const v8.Value,
};
pub fn UndefinedOr(comptime T: type) type {
return union(enum) {
undefined: void,
value: T,
};
}
// An interface for types that want to have their jsScopeEnd function be
// called when the call context ends
const CallScopeEndCallback = struct {
ptr: *anyopaque,
callScopeEndFn: *const fn (ptr: *anyopaque) void,
fn init(ptr: anytype) CallScopeEndCallback {
const T = @TypeOf(ptr);
const ptr_info = @typeInfo(T);
const gen = struct {
pub fn callScopeEnd(pointer: *anyopaque) void {
const self: T = @ptrCast(@alignCast(pointer));
return ptr_info.pointer.child.jsCallScopeEnd(self);
}
};
return .{
.ptr = ptr,
.callScopeEndFn = gen.callScopeEnd,
};
}
pub fn callScopeEnd(self: CallScopeEndCallback) void {
self.callScopeEndFn(self.ptr);
}
};
// Callback called on global's property missing.
// Return true to intercept the execution or false to let the call
// continue the chain.
pub const GlobalMissingCallback = struct {
ptr: *anyopaque,
missingFn: *const fn (ptr: *anyopaque, name: []const u8, ctx: *Context) bool,
pub fn init(ptr: anytype) GlobalMissingCallback {
const T = @TypeOf(ptr);
const ptr_info = @typeInfo(T);
const gen = struct {
pub fn missing(pointer: *anyopaque, name: []const u8, ctx: *Context) bool {
const self: T = @ptrCast(@alignCast(pointer));
return ptr_info.pointer.child.missing(self, name, ctx);
}
};
return .{
.ptr = ptr,
.missingFn = gen.missing,
};
}
pub fn missing(self: GlobalMissingCallback, name: []const u8, ctx: *Context) bool {
return self.missingFn(self.ptr, name, ctx);
}
};
// Attributes that return a primitive type are setup directly on the
// FunctionTemplate when the Env is setup. More complex types need a v8.Context
// and cannot be set directly on the FunctionTemplate.
// We default to saying types are primitives because that's mostly what
// we have. If we add a new complex type that isn't explictly handled here,
// we'll get a compiler error in simpleZigValueToJs, and can then explicitly
// add the type here.
pub fn isComplexAttributeType(ti: std.builtin.Type) bool {
return switch (ti) {
.array => true,
else => false,
};
}
// These are simple types that we can convert to JS with only an isolate. This
// is separated from the Caller's zigValueToJs to make it available when we
// don't have a caller (i.e., when setting static attributes on types)
pub fn simpleZigValueToJs(isolate: v8.Isolate, value: anytype, comptime fail: bool, comptime null_as_undefined: bool) if (fail) v8.Value else ?v8.Value {
pub fn simpleZigValueToJs(isolate: Isolate, value: anytype, comptime fail: bool, comptime null_as_undefined: bool) if (fail) *const v8.Value else ?*const v8.Value {
switch (@typeInfo(@TypeOf(value))) {
.void => return v8.initUndefined(isolate).toValue(),
.null => if (comptime null_as_undefined) return v8.initUndefined(isolate).toValue() else return v8.initNull(isolate).toValue(),
.bool => return v8.getValue(if (value) v8.initTrue(isolate) else v8.initFalse(isolate)),
.int => |n| switch (n.signedness) {
.signed => {
if (value > 0 and value <= 4_294_967_295) {
return v8.Integer.initU32(isolate, @intCast(value)).toValue();
}
if (value >= -2_147_483_648 and value <= 2_147_483_647) {
return v8.Integer.initI32(isolate, @intCast(value)).toValue();
}
if (comptime n.bits <= 64) {
return v8.getValue(v8.BigInt.initI64(isolate, @intCast(value)));
}
@compileError(@typeName(value) ++ " is not supported");
},
.unsigned => {
if (value <= 4_294_967_295) {
return v8.Integer.initU32(isolate, @intCast(value)).toValue();
}
if (comptime n.bits <= 64) {
return v8.getValue(v8.BigInt.initU64(isolate, @intCast(value)));
}
@compileError(@typeName(value) ++ " is not supported");
},
.void => return isolate.initUndefined(),
.null => if (comptime null_as_undefined) return isolate.initUndefined() else return isolate.initNull(),
.bool => return if (value) isolate.initTrue() else isolate.initFalse(),
.int => |n| {
if (comptime n.bits <= 32) {
return @ptrCast(isolate.initInteger(value).handle);
}
if (value >= 0 and value <= 4_294_967_295) {
return @ptrCast(isolate.initInteger(@as(u32, @intCast(value))).handle);
}
return @ptrCast(isolate.initBigInt(value).handle);
},
.comptime_int => {
if (value >= 0) {
if (value <= 4_294_967_295) {
return v8.Integer.initU32(isolate, @intCast(value)).toValue();
}
return v8.BigInt.initU64(isolate, @intCast(value)).toValue();
if (value > -2_147_483_648 and value <= 4_294_967_295) {
return @ptrCast(isolate.initInteger(value).handle);
}
if (value >= -2_147_483_648) {
return v8.Integer.initI32(isolate, @intCast(value)).toValue();
}
return v8.BigInt.initI64(isolate, @intCast(value)).toValue();
},
.comptime_float => return v8.Number.init(isolate, value).toValue(),
.float => |f| switch (f.bits) {
64 => return v8.Number.init(isolate, value).toValue(),
32 => return v8.Number.init(isolate, @floatCast(value)).toValue(),
else => @compileError(@typeName(value) ++ " is not supported"),
return @ptrCast(isolate.initBigInt(value).handle);
},
.float, .comptime_float => return @ptrCast(isolate.initNumber(value).handle),
.pointer => |ptr| {
if (ptr.size == .slice and ptr.child == u8) {
return v8.String.initUtf8(isolate, value).toValue();
return @ptrCast(isolate.initStringHandle(value));
}
if (ptr.size == .one) {
const one_info = @typeInfo(ptr.child);
if (one_info == .array and one_info.array.child == u8) {
return v8.String.initUtf8(isolate, value).toValue();
return @ptrCast(isolate.initStringHandle(value));
}
}
},
@@ -317,22 +123,21 @@ pub fn simpleZigValueToJs(isolate: v8.Isolate, value: anytype, comptime fail: bo
return simpleZigValueToJs(isolate, v, fail, null_as_undefined);
}
if (comptime null_as_undefined) {
return v8.initUndefined(isolate).toValue();
return isolate.initUndefined();
}
return v8.initNull(isolate).toValue();
return isolate.initNull();
},
.@"struct" => {
switch (@TypeOf(value)) {
string.String => return isolate.initStringHandle(value.str()),
ArrayBuffer => {
const values = value.values;
const len = values.len;
var array_buffer: v8.ArrayBuffer = undefined;
const backing_store = v8.BackingStore.init(isolate, len);
const data: [*]u8 = @ptrCast(@alignCast(backing_store.getData()));
const backing_store = v8.v8__ArrayBuffer__NewBackingStore(isolate.handle, len);
const data: [*]u8 = @ptrCast(@alignCast(v8.v8__BackingStore__Data(backing_store)));
@memcpy(data[0..len], @as([]const u8, @ptrCast(values))[0..len]);
array_buffer = v8.ArrayBuffer.initWithBackingStore(isolate, &backing_store.toSharedPtr());
return .{ .handle = array_buffer.handle };
const backing_store_ptr = v8.v8__BackingStore__TO_SHARED_PTR(backing_store);
return @ptrCast(v8.v8__ArrayBuffer__New2(isolate.handle, &backing_store_ptr).?);
},
// zig fmt: off
TypedArray(u8), TypedArray(u16), TypedArray(u32), TypedArray(u64),
@@ -349,37 +154,38 @@ pub fn simpleZigValueToJs(isolate: v8.Isolate, value: anytype, comptime fail: bo
else => @compileError("Invalid TypeArray type: " ++ @typeName(value_type)),
};
var array_buffer: v8.ArrayBuffer = undefined;
var array_buffer: *const v8.ArrayBuffer = undefined;
if (len == 0) {
array_buffer = v8.ArrayBuffer.init(isolate, 0);
array_buffer = v8.v8__ArrayBuffer__New(isolate.handle, 0).?;
} else {
const buffer_len = len * bits / 8;
const backing_store = v8.BackingStore.init(isolate, buffer_len);
const data: [*]u8 = @ptrCast(@alignCast(backing_store.getData()));
const backing_store = v8.v8__ArrayBuffer__NewBackingStore(isolate.handle, buffer_len).?;
const data: [*]u8 = @ptrCast(@alignCast(v8.v8__BackingStore__Data(backing_store)));
@memcpy(data[0..buffer_len], @as([]const u8, @ptrCast(values))[0..buffer_len]);
array_buffer = v8.ArrayBuffer.initWithBackingStore(isolate, &backing_store.toSharedPtr());
const backing_store_ptr = v8.v8__BackingStore__TO_SHARED_PTR(backing_store);
array_buffer = v8.v8__ArrayBuffer__New2(isolate.handle, &backing_store_ptr).?;
}
switch (@typeInfo(value_type)) {
.int => |n| switch (n.signedness) {
.unsigned => switch (n.bits) {
8 => return v8.Uint8Array.init(array_buffer, 0, len).toValue(),
16 => return v8.Uint16Array.init(array_buffer, 0, len).toValue(),
32 => return v8.Uint32Array.init(array_buffer, 0, len).toValue(),
64 => return v8.BigUint64Array.init(array_buffer, 0, len).toValue(),
8 => return @ptrCast(v8.v8__Uint8Array__New(array_buffer, 0, len).?),
16 => return @ptrCast(v8.v8__Uint16Array__New(array_buffer, 0, len).?),
32 => return @ptrCast(v8.v8__Uint32Array__New(array_buffer, 0, len).?),
64 => return @ptrCast(v8.v8__BigUint64Array__New(array_buffer, 0, len).?),
else => {},
},
.signed => switch (n.bits) {
8 => return v8.Int8Array.init(array_buffer, 0, len).toValue(),
16 => return v8.Int16Array.init(array_buffer, 0, len).toValue(),
32 => return v8.Int32Array.init(array_buffer, 0, len).toValue(),
64 => return v8.BigInt64Array.init(array_buffer, 0, len).toValue(),
8 => return @ptrCast(v8.v8__Int8Array__New(array_buffer, 0, len).?),
16 => return @ptrCast(v8.v8__Int16Array__New(array_buffer, 0, len).?),
32 => return @ptrCast(v8.v8__Int32Array__New(array_buffer, 0, len).?),
64 => return @ptrCast(v8.v8__BigInt64Array__New(array_buffer, 0, len).?),
else => {},
},
},
.float => |f| switch (f.bits) {
32 => return v8.Float32Array.init(array_buffer, 0, len).toValue(),
64 => return v8.Float64Array.init(array_buffer, 0, len).toValue(),
32 => return @ptrCast(v8.v8__Float32Array__New(array_buffer, 0, len).?),
64 => return @ptrCast(v8.v8__Float64Array__New(array_buffer, 0, len).?),
else => {},
},
else => {},
@@ -388,6 +194,7 @@ pub fn simpleZigValueToJs(isolate: v8.Isolate, value: anytype, comptime fail: bo
// but this can never be valid.
@compileError("Invalid TypeArray type: " ++ @typeName(value_type));
},
inline String, BigInt, Integer, Number, Value, Object => return value.handle,
else => {},
}
},
@@ -406,76 +213,6 @@ pub fn simpleZigValueToJs(isolate: v8.Isolate, value: anytype, comptime fail: bo
return null;
}
pub fn _createException(isolate: v8.Isolate, msg: []const u8) v8.Value {
return v8.Exception.initError(v8.String.initUtf8(isolate, msg));
}
pub fn classNameForStruct(comptime Struct: type) []const u8 {
if (@hasDecl(Struct, "js_name")) {
return Struct.js_name;
}
@setEvalBranchQuota(10_000);
const full_name = @typeName(Struct);
const last = std.mem.lastIndexOfScalar(u8, full_name, '.') orelse return full_name;
return full_name[last + 1 ..];
}
// When we return a Zig object to V8, we put it on the heap and pass it into
// v8 as an *anyopaque (i.e. void *). When V8 gives us back the value, say, as a
// function parameter, we know what type it _should_ be.
//
// In a simple/perfect world, we could use this knowledge to cast the *anyopaque
// to the parameter type:
// const arg: @typeInfo(@TypeOf(function)).@"fn".params[0] = @ptrCast(v8_data);
//
// But there are 2 reasons we can't do that.
//
// == Reason 1 ==
// The JS code might pass the wrong type:
//
// var cat = new Cat();
// cat.setOwner(new Cat());
//
// The zig_setOwner method expects the 2nd parameter to be an *Owner, but
// the JS code passed a *Cat.
//
// To solve this issue, we tag every returned value so that we can check what
// type it is. In the above case, we'd expect an *Owner, but the tag would tell
// us that we got a *Cat. We use the type index in our Types lookup as the tag.
//
// == Reason 2 ==
// Because of prototype inheritance, even "correct" code can be a challenge. For
// example, say the above JavaScript is fixed:
//
// var cat = new Cat();
// cat.setOwner(new Owner("Leto"));
//
// The issue is that setOwner might not expect an *Owner, but rather a
// *Person, which is the prototype for Owner. Now our Zig code is expecting
// a *Person, but it was (correctly) given an *Owner.
// For this reason, we also store the prototype chain.
pub const TaggedAnyOpaque = struct {
prototype_len: u16,
prototype_chain: [*]const PrototypeChainEntry,
// Ptr to the Zig instance. Between the context where it's called (i.e.
// we have the comptime parameter info for all functions), and the index field
// we can figure out what type this is.
value: *anyopaque,
// When we're asked to describe an object via the Inspector, we _must_ include
// the proper subtype (and description) fields in the returned JSON.
// V8 will give us a Value and ask us for the subtype. From the v8.Value we
// can get a v8.Object, and from the v8.Object, we can get out TaggedAnyOpaque
// which is where we store the subtype.
subtype: ?bridge.SubType,
};
pub const PrototypeChainEntry = struct {
index: bridge.JsApiLookup.BackingInt,
offset: u16, // offset to the _proto field
};
// These are here, and not in Inspector.zig, because Inspector.zig isn't always
// included (e.g. in the wpt build).
@@ -483,10 +220,10 @@ pub const PrototypeChainEntry = struct {
// it'll call this function to gets its [optional] subtype - which, from V8's
// point of view, is an arbitrary string.
pub export fn v8_inspector__Client__IMPL__valueSubtype(
_: *v8.c.InspectorClientImpl,
c_value: *const v8.C_Value,
_: *v8.InspectorClientImpl,
c_value: *const v8.Value,
) callconv(.c) [*c]const u8 {
const external_entry = Inspector.getTaggedAnyOpaque(.{ .handle = c_value }) orelse return null;
const external_entry = Inspector.getTaggedOpaque(c_value) orelse return null;
return if (external_entry.subtype) |st| @tagName(st) else null;
}
@@ -495,19 +232,19 @@ pub export fn v8_inspector__Client__IMPL__valueSubtype(
// present, even if it's empty. So if we have a subType for the value, we'll
// put an empty description.
pub export fn v8_inspector__Client__IMPL__descriptionForValueSubtype(
_: *v8.c.InspectorClientImpl,
v8_context: *const v8.C_Context,
c_value: *const v8.C_Value,
_: *v8.InspectorClientImpl,
v8_context: *const v8.Context,
c_value: *const v8.Value,
) callconv(.c) [*c]const u8 {
_ = v8_context;
// We _must_ include a non-null description in order for the subtype value
// to be included. Besides that, I don't know if the value has any meaning
const external_entry = Inspector.getTaggedAnyOpaque(.{ .handle = c_value }) orelse return null;
const external_entry = Inspector.getTaggedOpaque(c_value) orelse return null;
return if (external_entry.subtype == null) null else "";
}
test "TaggedAnyOpaque" {
// If we grow this, fine, but it should be a conscious decision
try std.testing.expectEqual(24, @sizeOf(TaggedAnyOpaque));
try std.testing.expectEqual(24, @sizeOf(@import("TaggedOpaque.zig")));
}

View File

@@ -17,12 +17,14 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const h5e = @import("html5ever.zig");
const Page = @import("../Page.zig");
const Node = @import("../webapi/Node.zig");
const Element = @import("../webapi/Element.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
pub const ParsedNode = struct {
node: *Node,
@@ -98,6 +100,29 @@ pub fn parse(self: *Parser, html: []const u8) void {
);
}
pub fn parseXML(self: *Parser, xml: []const u8) void {
h5e.xml5ever_parse_document(
xml.ptr,
xml.len,
&self.container,
self,
createXMLElementCallback,
getDataCallback,
appendCallback,
parseErrorCallback,
popCallback,
createCommentCallback,
createProcessingInstruction,
appendDoctypeToDocument,
addAttrsIfMissingCallback,
getTemplateContentsCallback,
removeFromParentCallback,
reparentChildrenCallback,
appendBeforeSiblingCallback,
appendBasedOnParentNodeCallback,
);
}
pub fn parseFragment(self: *Parser, html: []const u8) void {
h5e.html5ever_parse_fragment(
html.ptr,
@@ -139,7 +164,7 @@ pub const Streaming = struct {
}
pub fn start(self: *Streaming) !void {
std.debug.assert(self.handle == null);
lp.assert(self.handle == null, "Parser.start non-null handle", .{});
self.handle = h5e.html5ever_streaming_parser_create(
&self.parser.container,
@@ -202,17 +227,26 @@ fn _popCallback(self: *Parser, node: *Node) !void {
}
fn createElementCallback(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualName, attributes: h5e.AttributeIterator) callconv(.c) ?*anyopaque {
return _createElementCallbackWithDefaultnamespace(ctx, data, qname, attributes, .unknown);
}
fn createXMLElementCallback(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualName, attributes: h5e.AttributeIterator) callconv(.c) ?*anyopaque {
return _createElementCallbackWithDefaultnamespace(ctx, data, qname, attributes, .xml);
}
fn _createElementCallbackWithDefaultnamespace(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualName, attributes: h5e.AttributeIterator, default_namespace: Element.Namespace) ?*anyopaque {
const self: *Parser = @ptrCast(@alignCast(ctx));
return self._createElementCallback(data, qname, attributes) catch |err| {
return self._createElementCallback(data, qname, attributes, default_namespace) catch |err| {
self.err = .{ .err = err, .source = .create_element };
return null;
};
}
fn _createElementCallback(self: *Parser, data: *anyopaque, qname: h5e.QualName, attributes: h5e.AttributeIterator) !*anyopaque {
fn _createElementCallback(self: *Parser, data: *anyopaque, qname: h5e.QualName, attributes: h5e.AttributeIterator, default_namespace: Element.Namespace) !*anyopaque {
const page = self.page;
const name = qname.local.slice();
const namespace = qname.ns.slice();
const node = try page.createElement(namespace, name, attributes);
const namespace_string = qname.ns.slice();
const namespace = if (namespace_string.len == 0) default_namespace else Element.Namespace.parse(namespace_string);
const node = try page.createElementNS(namespace, name, attributes);
const pn = try self.arena.create(ParsedNode);
pn.* = .{
@@ -325,7 +359,7 @@ fn getDataCallback(ctx: *anyopaque) callconv(.c) *anyopaque {
const pn: *ParsedNode = @ptrCast(@alignCast(ctx));
// For non-elements, data is null. But, we expect this to only ever
// be called for elements.
std.debug.assert(pn.data != null);
lp.assert(pn.data != null, "Parser.getDataCallback null data", .{});
return pn.data.?;
}
@@ -340,6 +374,17 @@ fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !
switch (node_or_text.toUnion()) {
.node => |cpn| {
const child = getNode(cpn);
if (child._parent) |previous_parent| {
// html5ever says this can't happen, but we might be screwing up
// the node on our side. We shouldn't be, but we're seeing this
// in the wild, and I'm not sure why. In debug, let's crash so
// we can try to figure it out. In release, let's disconnect
// the child first.
if (comptime IS_DEBUG) {
unreachable;
}
self.page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent.isConnected() });
}
try self.page.appendNew(parent, .{ .node = child });
},
.text => |txt| try self.page.appendNew(parent, .{ .text = txt }),

View File

@@ -171,3 +171,24 @@ pub const NodeOrText = extern struct {
text: []const u8,
};
};
pub extern "c" fn xml5ever_parse_document(
html: [*c]const u8,
len: usize,
doc: *anyopaque,
ctx: *anyopaque,
createElementCallback: *const fn (ctx: *anyopaque, data: *anyopaque, QualName, AttributeIterator) callconv(.c) ?*anyopaque,
elemNameCallback: *const fn (node_ref: *anyopaque) callconv(.c) *anyopaque,
appendCallback: *const fn (ctx: *anyopaque, parent_ref: *anyopaque, NodeOrText) callconv(.c) void,
parseErrorCallback: *const fn (ctx: *anyopaque, StringSlice) callconv(.c) void,
popCallback: *const fn (ctx: *anyopaque, node_ref: *anyopaque) callconv(.c) void,
createCommentCallback: *const fn (ctx: *anyopaque, StringSlice) callconv(.c) ?*anyopaque,
createProcessingInstruction: *const fn (ctx: *anyopaque, StringSlice, StringSlice) callconv(.c) ?*anyopaque,
appendDoctypeToDocument: *const fn (ctx: *anyopaque, StringSlice, StringSlice, StringSlice) callconv(.c) void,
addAttrsIfMissingCallback: *const fn (ctx: *anyopaque, target_ref: *anyopaque, AttributeIterator) callconv(.c) void,
getTemplateContentsCallback: *const fn (ctx: *anyopaque, target_ref: *anyopaque) callconv(.c) ?*anyopaque,
removeFromParentCallback: *const fn (ctx: *anyopaque, target_ref: *anyopaque) callconv(.c) void,
reparentChildrenCallback: *const fn (ctx: *anyopaque, node_ref: *anyopaque, new_parent_ref: *anyopaque) callconv(.c) void,
appendBeforeSiblingCallback: *const fn (ctx: *anyopaque, sibling_ref: *anyopaque, NodeOrText) callconv(.c) void,
appendBasedOnParentNodeCallback: *const fn (ctx: *anyopaque, element_ref: *anyopaque, prev_element_ref: *anyopaque, NodeOrText) callconv(.c) void,
) void;

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,35 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=CanvasRenderingContext2D>
{
const element = document.createElement("canvas");
const ctx = element.getContext("2d");
testing.expectEqual(true, ctx instanceof CanvasRenderingContext2D);
// We can't really test this but let's try to call it at least.
ctx.fillRect(0, 0, 0, 0);
}
</script>
<script id=CanvasRenderingContext2D#fillStyle>
{
const element = document.createElement("canvas");
const ctx = element.getContext("2d");
// Black by default.
testing.expectEqual(ctx.fillStyle, "#000000");
ctx.fillStyle = "red";
testing.expectEqual(ctx.fillStyle, "#ff0000");
ctx.fillStyle = "rebeccapurple";
testing.expectEqual(ctx.fillStyle, "#663399");
// No changes made if color is invalid.
ctx.fillStyle = "invalid-color";
testing.expectEqual(ctx.fillStyle, "#663399");
ctx.fillStyle = "#fc0";
testing.expectEqual(ctx.fillStyle, "#ffcc00");
ctx.fillStyle = "#ff0000";
testing.expectEqual(ctx.fillStyle, "#ff0000");
ctx.fillStyle = "#fF00000F";
testing.expectEqual(ctx.fillStyle, "rgba(255, 0, 0, 0.06)");
}
</script>

View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=WebGLRenderingContext#getSupportedExtensions>
{
const element = document.createElement("canvas");
const ctx = element.getContext("webgl");
testing.expectEqual(true, ctx instanceof WebGLRenderingContext);
const supportedExtensions = ctx.getSupportedExtensions();
// The order Chrome prefer.
const expectedExtensions = [
"ANGLE_instanced_arrays",
"EXT_blend_minmax",
"EXT_clip_control",
"EXT_color_buffer_half_float",
"EXT_depth_clamp",
"EXT_disjoint_timer_query",
"EXT_float_blend",
"EXT_frag_depth",
"EXT_polygon_offset_clamp",
"EXT_shader_texture_lod",
"EXT_texture_compression_bptc",
"EXT_texture_compression_rgtc",
"EXT_texture_filter_anisotropic",
"EXT_texture_mirror_clamp_to_edge",
"EXT_sRGB",
"KHR_parallel_shader_compile",
"OES_element_index_uint",
"OES_fbo_render_mipmap",
"OES_standard_derivatives",
"OES_texture_float",
"OES_texture_float_linear",
"OES_texture_half_float",
"OES_texture_half_float_linear",
"OES_vertex_array_object",
"WEBGL_blend_func_extended",
"WEBGL_color_buffer_float",
"WEBGL_compressed_texture_astc",
"WEBGL_compressed_texture_etc",
"WEBGL_compressed_texture_etc1",
"WEBGL_compressed_texture_pvrtc",
"WEBGL_compressed_texture_s3tc",
"WEBGL_compressed_texture_s3tc_srgb",
"WEBGL_debug_renderer_info",
"WEBGL_debug_shaders",
"WEBGL_depth_texture",
"WEBGL_draw_buffers",
"WEBGL_lose_context",
"WEBGL_multi_draw",
"WEBGL_polygon_mode"
];
testing.expectEqual(expectedExtensions.length, supportedExtensions.length);
for (let i = 0; i < expectedExtensions.length; i++) {
testing.expectEqual(expectedExtensions[i], supportedExtensions[i]);
}
}
</script>
<script id=WebGLRenderingCanvas#getExtension>
// WEBGL_debug_renderer_info
{
const element = document.createElement("canvas");
const ctx = element.getContext("webgl");
const rendererInfo = ctx.getExtension("WEBGL_debug_renderer_info");
testing.expectEqual(true, rendererInfo instanceof WEBGL_debug_renderer_info);
const { UNMASKED_VENDOR_WEBGL, UNMASKED_RENDERER_WEBGL } = rendererInfo;
testing.expectEqual(UNMASKED_VENDOR_WEBGL, 0x9245);
testing.expectEqual(UNMASKED_RENDERER_WEBGL, 0x9246);
testing.expectEqual("", ctx.getParameter(UNMASKED_VENDOR_WEBGL));
testing.expectEqual("", ctx.getParameter(UNMASKED_RENDERER_WEBGL));
}
// WEBGL_lose_context
{
const element = document.createElement("canvas");
const ctx = element.getContext("webgl");
const loseContext = ctx.getExtension("WEBGL_lose_context");
testing.expectEqual(true, loseContext instanceof WEBGL_lose_context);
loseContext.loseContext();
loseContext.restoreContext();
}
</script>

View File

@@ -201,8 +201,8 @@ cdataClassName<!DOCTYPE html>
root.appendChild(cdata);
root.appendChild(elem2);
testing.expectEqual('LAST', cdata.nextElementSibling.tagName);
testing.expectEqual('FIRST', cdata.previousElementSibling.tagName);
testing.expectEqual('last', cdata.nextElementSibling.tagName);
testing.expectEqual('first', cdata.previousElementSibling.tagName);
}
</script>

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
</head>
<body>
<h1>Test Page</h1>
<nav>
<a href="/page1" id="link1">First Link</a>
<a href="/page2" id="link2">Second Link</a>
</nav>
<form id="testForm" action="/submit" method="post">
<label for="username">Username:</label>
<input type="text" id="username" name="username" placeholder="Enter username">
<label for="email">Email:</label>
<input type="email" id="email" name="email" placeholder="Enter email">
<label for="password">Password:</label>
<input type="password" id="password" name="password">
<button type="submit">Submit</button>
</form>
</body>
</html>

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

@@ -54,3 +54,68 @@
const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
testing.expectEqual(true, regex.test(uuid));
</script> -->
<script id=SubtleCrypto>
testing.expectEqual(true, crypto.subtle instanceof SubtleCrypto);
</script>
<script id=sign-and-verify-hmac>
testing.async(async () => {
let key = await crypto.subtle.generateKey(
{
name: "HMAC",
hash: { name: "SHA-512" },
},
true,
["sign", "verify"],
);
testing.expectEqual(true, key instanceof CryptoKey);
const raw = await crypto.subtle.exportKey("raw", key);
testing.expectEqual(128, raw.byteLength);
const encoder = new TextEncoder();
const signature = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode("Hello, world!")
);
testing.expectEqual(true, signature instanceof ArrayBuffer);
const result = await window.crypto.subtle.verify(
{ name: "HMAC" },
key,
signature,
encoder.encode("Hello, world!")
);
testing.expectEqual(true, result);
});
</script>
<script id=derive-shared-key-x25519>
testing.async(async () => {
const { privateKey, publicKey } = await crypto.subtle.generateKey(
{ name: "X25519" },
true,
["deriveBits"],
);
testing.expectEqual(true, privateKey instanceof CryptoKey);
testing.expectEqual(true, publicKey instanceof CryptoKey);
const sharedKey = await crypto.subtle.deriveBits(
{
name: "X25519",
public: publicKey,
},
privateKey,
128,
);
testing.expectEqual(16, sharedKey.byteLength);
});
</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

@@ -27,329 +27,329 @@
customElements.define('my-early', MyEarly);
testing.expectEqual(true, early.upgraded);
testing.expectEqual(1, constructorCalled);
testing.expectEqual(1, connectedCalled);
// testing.expectEqual(1, connectedCalled);
}
{
let order = [];
// {
// let order = [];
class UpgradeParent extends HTMLElement {
constructor() {
super();
order.push('parent-constructor');
}
connectedCallback() {
order.push('parent-connected');
}
}
class UpgradeChild extends HTMLElement {
constructor() {
super();
order.push('child-constructor');
}
connectedCallback() {
order.push('child-connected');
}
}
// class UpgradeParent extends HTMLElement {
// constructor() {
// super();
// order.push('parent-constructor');
// }
// connectedCallback() {
// order.push('parent-connected');
// }
// }
// class UpgradeChild extends HTMLElement {
// constructor() {
// super();
// order.push('child-constructor');
// }
// connectedCallback() {
// order.push('child-connected');
// }
// }
const container = document.createElement('div');
container.innerHTML = '<upgrade-parent><upgrade-child></upgrade-child></upgrade-parent>';
document.body.appendChild(container);
testing.expectEqual(0, order.length);
// const container = document.createElement('div');
// container.innerHTML = '<upgrade-parent><upgrade-child></upgrade-child></upgrade-parent>';
// document.body.appendChild(container);
// testing.expectEqual(0, order.length);
customElements.define('upgrade-parent', UpgradeParent);
testing.expectEqual(2, order.length);
testing.expectEqual('parent-constructor', order[0]);
testing.expectEqual('parent-connected', order[1]);
customElements.define('upgrade-child', UpgradeChild);
testing.expectEqual(4, order.length);
testing.expectEqual('child-constructor', order[2]);
testing.expectEqual('child-connected', order[3]);
}
// customElements.define('upgrade-parent', UpgradeParent);
// testing.expectEqual(2, order.length);
// testing.expectEqual('parent-constructor', order[0]);
// testing.expectEqual('parent-connected', order[1]);
// customElements.define('upgrade-child', UpgradeChild);
// testing.expectEqual(4, order.length);
// testing.expectEqual('child-constructor', order[2]);
// testing.expectEqual('child-connected', order[3]);
// }
{
let connectedCalled = 0;
// {
// let connectedCalled = 0;
class DetachedUpgrade extends HTMLElement {
connectedCallback() {
connectedCalled++;
}
}
const container = document.createElement('div');
container.innerHTML = '<detached-upgrade></detached-upgrade>';
testing.expectEqual(0, connectedCalled);
customElements.define('detached-upgrade', DetachedUpgrade);
testing.expectEqual(0, connectedCalled);
document.body.appendChild(container);
testing.expectEqual(1, connectedCalled);
}
{
let constructorCalled = 0;
let connectedCalled = 0;
class ManualUpgrade extends HTMLElement {
constructor() {
super();
constructorCalled++;
this.manuallyUpgraded = true;
}
connectedCallback() {
connectedCalled++;
}
}
// class DetachedUpgrade extends HTMLElement {
// connectedCallback() {
// connectedCalled++;
// }
// }
// const container = document.createElement('div');
// container.innerHTML = '<detached-upgrade></detached-upgrade>';
// testing.expectEqual(0, connectedCalled);
// customElements.define('detached-upgrade', DetachedUpgrade);
// testing.expectEqual(0, connectedCalled);
// document.body.appendChild(container);
// testing.expectEqual(1, connectedCalled);
// }
// {
// let constructorCalled = 0;
// let connectedCalled = 0;
// class ManualUpgrade extends HTMLElement {
// constructor() {
// super();
// constructorCalled++;
// this.manuallyUpgraded = true;
// }
// connectedCallback() {
// connectedCalled++;
// }
// }
customElements.define('manual-upgrade', ManualUpgrade);
// customElements.define('manual-upgrade', ManualUpgrade);
const container = document.createElement('div');
container.innerHTML = '<manual-upgrade id="m1"><manual-upgrade id="m2"></manual-upgrade></manual-upgrade>';
// const container = document.createElement('div');
// container.innerHTML = '<manual-upgrade id="m1"><manual-upgrade id="m2"></manual-upgrade></manual-upgrade>';
testing.expectEqual(2, constructorCalled);
testing.expectEqual(0, connectedCalled);
// testing.expectEqual(2, constructorCalled);
// testing.expectEqual(0, connectedCalled);
customElements.upgrade(container);
// customElements.upgrade(container);
testing.expectEqual(2, constructorCalled);
testing.expectEqual(0, connectedCalled);
const m1 = container.querySelector('#m1');
const m2 = container.querySelector('#m2');
testing.expectEqual(true, m1.manuallyUpgraded);
testing.expectEqual(true, m2.manuallyUpgraded);
document.body.appendChild(container);
testing.expectEqual(2, connectedCalled);
}
{
let alreadyUpgradedCalled = 0;
class AlreadyUpgraded extends HTMLElement {
constructor() {
super();
alreadyUpgradedCalled++;
}
}
// testing.expectEqual(2, constructorCalled);
// testing.expectEqual(0, connectedCalled);
// const m1 = container.querySelector('#m1');
// const m2 = container.querySelector('#m2');
// testing.expectEqual(true, m1.manuallyUpgraded);
// testing.expectEqual(true, m2.manuallyUpgraded);
// document.body.appendChild(container);
// testing.expectEqual(2, connectedCalled);
// }
// {
// let alreadyUpgradedCalled = 0;
// class AlreadyUpgraded extends HTMLElement {
// constructor() {
// super();
// alreadyUpgradedCalled++;
// }
// }
const elem = document.createElement('div');
elem.innerHTML = '<already-upgraded></already-upgraded>';
document.body.appendChild(elem);
// const elem = document.createElement('div');
// elem.innerHTML = '<already-upgraded></already-upgraded>';
// document.body.appendChild(elem);
customElements.define('already-upgraded', AlreadyUpgraded);
testing.expectEqual(1, alreadyUpgradedCalled);
// customElements.define('already-upgraded', AlreadyUpgraded);
// testing.expectEqual(1, alreadyUpgradedCalled);
customElements.upgrade(elem);
testing.expectEqual(1, alreadyUpgradedCalled);
}
// customElements.upgrade(elem);
// testing.expectEqual(1, alreadyUpgradedCalled);
// }
{
let attributeChangedCalls = [];
// {
// let attributeChangedCalls = [];
class UpgradeWithAttrs extends HTMLElement {
static get observedAttributes() {
return ['data-foo', 'data-bar'];
}
// class UpgradeWithAttrs extends HTMLElement {
// static get observedAttributes() {
// return ['data-foo', 'data-bar'];
// }
attributeChangedCallback(name, oldValue, newValue) {
attributeChangedCalls.push({ name, oldValue, newValue });
}
}
// attributeChangedCallback(name, oldValue, newValue) {
// attributeChangedCalls.push({ name, oldValue, newValue });
// }
// }
const container = document.createElement('div');
container.innerHTML = '<upgrade-with-attrs data-foo="hello" data-bar="world"></upgrade-with-attrs>';
document.body.appendChild(container);
// const container = document.createElement('div');
// container.innerHTML = '<upgrade-with-attrs data-foo="hello" data-bar="world"></upgrade-with-attrs>';
// document.body.appendChild(container);
testing.expectEqual(0, attributeChangedCalls.length);
// testing.expectEqual(0, attributeChangedCalls.length);
customElements.define('upgrade-with-attrs', UpgradeWithAttrs);
// customElements.define('upgrade-with-attrs', UpgradeWithAttrs);
testing.expectEqual(2, attributeChangedCalls.length);
testing.expectEqual('data-foo', attributeChangedCalls[0].name);
testing.expectEqual(null, attributeChangedCalls[0].oldValue);
testing.expectEqual('hello', attributeChangedCalls[0].newValue);
testing.expectEqual('data-bar', attributeChangedCalls[1].name);
testing.expectEqual(null, attributeChangedCalls[1].oldValue);
testing.expectEqual('world', attributeChangedCalls[1].newValue);
}
// testing.expectEqual(2, attributeChangedCalls.length);
// testing.expectEqual('data-foo', attributeChangedCalls[0].name);
// testing.expectEqual(null, attributeChangedCalls[0].oldValue);
// testing.expectEqual('hello', attributeChangedCalls[0].newValue);
// testing.expectEqual('data-bar', attributeChangedCalls[1].name);
// testing.expectEqual(null, attributeChangedCalls[1].oldValue);
// testing.expectEqual('world', attributeChangedCalls[1].newValue);
// }
{
let attributeChangedCalls = [];
let connectedCalls = 0;
// {
// let attributeChangedCalls = [];
// let connectedCalls = 0;
class DetachedWithAttrs extends HTMLElement {
static get observedAttributes() {
return ['foo'];
}
// class DetachedWithAttrs extends HTMLElement {
// static get observedAttributes() {
// return ['foo'];
// }
attributeChangedCallback(name, oldValue, newValue) {
attributeChangedCalls.push({ name, oldValue, newValue });
}
// attributeChangedCallback(name, oldValue, newValue) {
// attributeChangedCalls.push({ name, oldValue, newValue });
// }
connectedCallback() {
connectedCalls++;
}
}
// connectedCallback() {
// connectedCalls++;
// }
// }
const container = document.createElement('div');
container.innerHTML = '<detached-with-attrs foo="bar"></detached-with-attrs>';
// const container = document.createElement('div');
// container.innerHTML = '<detached-with-attrs foo="bar"></detached-with-attrs>';
testing.expectEqual(0, attributeChangedCalls.length);
// testing.expectEqual(0, attributeChangedCalls.length);
customElements.define('detached-with-attrs', DetachedWithAttrs);
// customElements.define('detached-with-attrs', DetachedWithAttrs);
testing.expectEqual(0, attributeChangedCalls.length);
testing.expectEqual(0, connectedCalls);
// testing.expectEqual(0, attributeChangedCalls.length);
// testing.expectEqual(0, connectedCalls);
document.body.appendChild(container);
// document.body.appendChild(container);
testing.expectEqual(1, attributeChangedCalls.length);
testing.expectEqual('foo', attributeChangedCalls[0].name);
testing.expectEqual(null, attributeChangedCalls[0].oldValue);
testing.expectEqual('bar', attributeChangedCalls[0].newValue);
testing.expectEqual(1, connectedCalls);
}
// testing.expectEqual(1, attributeChangedCalls.length);
// testing.expectEqual('foo', attributeChangedCalls[0].name);
// testing.expectEqual(null, attributeChangedCalls[0].oldValue);
// testing.expectEqual('bar', attributeChangedCalls[0].newValue);
// testing.expectEqual(1, connectedCalls);
// }
{
let attributeChangedCalls = [];
let constructorCalled = 0;
// {
// let attributeChangedCalls = [];
// let constructorCalled = 0;
class ManualUpgradeWithAttrs extends HTMLElement {
static get observedAttributes() {
return ['x', 'y'];
}
// class ManualUpgradeWithAttrs extends HTMLElement {
// static get observedAttributes() {
// return ['x', 'y'];
// }
constructor() {
super();
constructorCalled++;
}
// constructor() {
// super();
// constructorCalled++;
// }
attributeChangedCallback(name, oldValue, newValue) {
attributeChangedCalls.push({ name, oldValue, newValue });
}
}
// attributeChangedCallback(name, oldValue, newValue) {
// attributeChangedCalls.push({ name, oldValue, newValue });
// }
// }
customElements.define('manual-upgrade-with-attrs', ManualUpgradeWithAttrs);
// customElements.define('manual-upgrade-with-attrs', ManualUpgradeWithAttrs);
const container = document.createElement('div');
container.innerHTML = '<manual-upgrade-with-attrs x="1" y="2"></manual-upgrade-with-attrs>';
// const container = document.createElement('div');
// container.innerHTML = '<manual-upgrade-with-attrs x="1" y="2"></manual-upgrade-with-attrs>';
testing.expectEqual(1, constructorCalled);
testing.expectEqual(2, attributeChangedCalls.length);
// testing.expectEqual(1, constructorCalled);
// testing.expectEqual(2, attributeChangedCalls.length);
const elem = container.querySelector('manual-upgrade-with-attrs');
elem.setAttribute('z', '3');
// const elem = container.querySelector('manual-upgrade-with-attrs');
// elem.setAttribute('z', '3');
customElements.upgrade(container);
// customElements.upgrade(container);
testing.expectEqual(1, constructorCalled);
testing.expectEqual(2, attributeChangedCalls.length);
}
// testing.expectEqual(1, constructorCalled);
// testing.expectEqual(2, attributeChangedCalls.length);
// }
{
let attributeChangedCalls = [];
// {
// let attributeChangedCalls = [];
class MixedAttrs extends HTMLElement {
static get observedAttributes() {
return ['watched'];
}
// class MixedAttrs extends HTMLElement {
// static get observedAttributes() {
// return ['watched'];
// }
attributeChangedCallback(name, oldValue, newValue) {
attributeChangedCalls.push({ name, oldValue, newValue });
}
}
// attributeChangedCallback(name, oldValue, newValue) {
// attributeChangedCalls.push({ name, oldValue, newValue });
// }
// }
const container = document.createElement('div');
container.innerHTML = '<mixed-attrs watched="yes" ignored="no" also-ignored="maybe"></mixed-attrs>';
document.body.appendChild(container);
// const container = document.createElement('div');
// container.innerHTML = '<mixed-attrs watched="yes" ignored="no" also-ignored="maybe"></mixed-attrs>';
// document.body.appendChild(container);
testing.expectEqual(0, attributeChangedCalls.length);
// testing.expectEqual(0, attributeChangedCalls.length);
customElements.define('mixed-attrs', MixedAttrs);
// customElements.define('mixed-attrs', MixedAttrs);
testing.expectEqual(1, attributeChangedCalls.length);
testing.expectEqual('watched', attributeChangedCalls[0].name);
testing.expectEqual('yes', attributeChangedCalls[0].newValue);
}
// testing.expectEqual(1, attributeChangedCalls.length);
// testing.expectEqual('watched', attributeChangedCalls[0].name);
// testing.expectEqual('yes', attributeChangedCalls[0].newValue);
// }
{
let attributeChangedCalls = [];
// {
// let attributeChangedCalls = [];
class EmptyAttr extends HTMLElement {
static get observedAttributes() {
return ['empty', 'non-empty'];
}
// class EmptyAttr extends HTMLElement {
// static get observedAttributes() {
// return ['empty', 'non-empty'];
// }
attributeChangedCallback(name, oldValue, newValue) {
attributeChangedCalls.push({ name, oldValue, newValue });
}
}
// attributeChangedCallback(name, oldValue, newValue) {
// attributeChangedCalls.push({ name, oldValue, newValue });
// }
// }
const container = document.createElement('div');
container.innerHTML = '<empty-attr empty="" non-empty="value"></empty-attr>';
document.body.appendChild(container);
// const container = document.createElement('div');
// container.innerHTML = '<empty-attr empty="" non-empty="value"></empty-attr>';
// document.body.appendChild(container);
customElements.define('empty-attr', EmptyAttr);
// customElements.define('empty-attr', EmptyAttr);
testing.expectEqual(2, attributeChangedCalls.length);
testing.expectEqual('empty', attributeChangedCalls[0].name);
testing.expectEqual('', attributeChangedCalls[0].newValue);
testing.expectEqual('non-empty', attributeChangedCalls[1].name);
testing.expectEqual('value', attributeChangedCalls[1].newValue);
}
// testing.expectEqual(2, attributeChangedCalls.length);
// testing.expectEqual('empty', attributeChangedCalls[0].name);
// testing.expectEqual('', attributeChangedCalls[0].newValue);
// testing.expectEqual('non-empty', attributeChangedCalls[1].name);
// testing.expectEqual('value', attributeChangedCalls[1].newValue);
// }
{
let parentCalls = [];
let childCalls = [];
// {
// let parentCalls = [];
// let childCalls = [];
class NestedParent extends HTMLElement {
static get observedAttributes() {
return ['parent-attr'];
}
// class NestedParent extends HTMLElement {
// static get observedAttributes() {
// return ['parent-attr'];
// }
attributeChangedCallback(name, oldValue, newValue) {
parentCalls.push({ name, oldValue, newValue });
}
}
// attributeChangedCallback(name, oldValue, newValue) {
// parentCalls.push({ name, oldValue, newValue });
// }
// }
class NestedChild extends HTMLElement {
static get observedAttributes() {
return ['child-attr'];
}
// class NestedChild extends HTMLElement {
// static get observedAttributes() {
// return ['child-attr'];
// }
attributeChangedCallback(name, oldValue, newValue) {
childCalls.push({ name, oldValue, newValue });
}
}
// attributeChangedCallback(name, oldValue, newValue) {
// childCalls.push({ name, oldValue, newValue });
// }
// }
const container = document.createElement('div');
container.innerHTML = '<nested-parent parent-attr="p"><nested-child child-attr="c"></nested-child></nested-parent>';
document.body.appendChild(container);
// const container = document.createElement('div');
// container.innerHTML = '<nested-parent parent-attr="p"><nested-child child-attr="c"></nested-child></nested-parent>';
// document.body.appendChild(container);
testing.expectEqual(0, parentCalls.length);
testing.expectEqual(0, childCalls.length);
// testing.expectEqual(0, parentCalls.length);
// testing.expectEqual(0, childCalls.length);
customElements.define('nested-parent', NestedParent);
// customElements.define('nested-parent', NestedParent);
testing.expectEqual(1, parentCalls.length);
testing.expectEqual('parent-attr', parentCalls[0].name);
testing.expectEqual('p', parentCalls[0].newValue);
testing.expectEqual(0, childCalls.length);
// testing.expectEqual(1, parentCalls.length);
// testing.expectEqual('parent-attr', parentCalls[0].name);
// testing.expectEqual('p', parentCalls[0].newValue);
// testing.expectEqual(0, childCalls.length);
customElements.define('nested-child', NestedChild);
// customElements.define('nested-child', NestedChild);
testing.expectEqual(1, parentCalls.length);
testing.expectEqual(1, childCalls.length);
testing.expectEqual('child-attr', childCalls[0].name);
testing.expectEqual('c', childCalls[0].newValue);
}
// testing.expectEqual(1, parentCalls.length);
// testing.expectEqual(1, childCalls.length);
// testing.expectEqual('child-attr', childCalls[0].name);
// testing.expectEqual('c', childCalls[0].newValue);
// }
</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);
@@ -19,12 +25,13 @@
testing.expectEqual('http://www.w3.org/XML/1998/namespace', xmlElement.namespaceURI);
const nullNsElement = document.createElementNS(null, 'span');
testing.expectEqual('SPAN', nullNsElement.tagName);
testing.expectEqual('http://www.w3.org/1999/xhtml', nullNsElement.namespaceURI);
testing.expectEqual('span', nullNsElement.tagName);
testing.expectEqual(null, nullNsElement.namespaceURI);
const unknownNsElement = document.createElementNS('http://example.com/unknown', 'custom');
testing.expectEqual('CUSTOM', unknownNsElement.tagName);
testing.expectEqual('http://www.w3.org/1999/xhtml', unknownNsElement.namespaceURI);
testing.expectEqual('custom', unknownNsElement.tagName);
// Should be http://example.com/unknown
testing.expectEqual('http://lightpanda.io/unsupported/namespace', unknownNsElement.namespaceURI);
const regularDiv = document.createElement('div');
testing.expectEqual('DIV', regularDiv.tagName);
@@ -36,5 +43,5 @@
testing.expectEqual('te:ST', custom.tagName);
testing.expectEqual('te', custom.prefix);
testing.expectEqual('ST', custom.localName);
testing.expectEqual('http://www.w3.org/1999/xhtml', custom.namespaceURI); // Should be test
testing.expectEqual('http://lightpanda.io/unsupported/namespace', custom.namespaceURI); // Should be test
</script>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,344 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<head>
<title>document.replaceChildren Tests</title>
</head>
<body>
<div id="test">Original content</div>
</body>
<script id=error_multiple_elements>
{
// Test that we cannot have more than one Element child
const doc = new Document();
const div1 = doc.createElement('div');
const div2 = doc.createElement('div');
testing.expectError('HierarchyRequest', () => {
doc.replaceChildren(div1, div2);
});
}
</script>
<script id=error_multiple_elements_via_fragment>
{
// Test that we cannot have more than one Element child via DocumentFragment
const doc = new Document();
const fragment = doc.createDocumentFragment();
fragment.appendChild(doc.createElement('div'));
fragment.appendChild(doc.createElement('span'));
testing.expectError('HierarchyRequest', () => {
doc.replaceChildren(fragment);
});
}
</script>
<script id=error_multiple_doctypes>
{
// Test that we cannot have more than one DocumentType child
const doc = new Document();
const doctype1 = doc.implementation.createDocumentType('html', '', '');
const doctype2 = doc.implementation.createDocumentType('html', '', '');
testing.expectError('HierarchyRequest', () => {
doc.replaceChildren(doctype1, doctype2);
});
}
</script>
<script id=error_text_node>
{
// Test that we cannot insert Text nodes directly into Document
const doc = new Document();
testing.expectError('HierarchyRequest', () => {
doc.replaceChildren('Just text');
});
}
</script>
<script id=error_text_with_element>
{
// Test that we cannot insert Text nodes even with valid Element
const doc = new Document();
const html = doc.createElement('html');
testing.expectError('HierarchyRequest', () => {
doc.replaceChildren('Text 1', html, 'Text 2');
});
}
</script>
<script id=error_append_multiple_elements>
{
// Test that append also validates
const doc = new Document();
doc.append(doc.createElement('html'));
const div = doc.createElement('div');
testing.expectError('HierarchyRequest', () => {
doc.append(div);
});
}
</script>
<script id=error_prepend_multiple_elements>
{
// Test that prepend also validates
const doc = new Document();
doc.prepend(doc.createElement('html'));
const div = doc.createElement('div');
testing.expectError('HierarchyRequest', () => {
doc.prepend(div);
});
}
</script>
<script id=error_append_text>
{
// Test that append rejects text nodes
const doc = new Document();
testing.expectError('HierarchyRequest', () => {
doc.append('text');
});
}
</script>
<script id=error_prepend_text>
{
// Test that prepend rejects text nodes
const doc = new Document();
testing.expectError('HierarchyRequest', () => {
doc.prepend('text');
});
}
</script>
<script id=replace_with_single_element>
{
const doc = new Document();
const html = doc.createElement('html');
html.id = 'replaced';
html.textContent = 'New content';
doc.replaceChildren(html);
testing.expectEqual(1, doc.childNodes.length);
testing.expectEqual(html, doc.firstChild);
testing.expectEqual('replaced', doc.firstChild.id);
}
</script>
<script id=replace_with_comments>
{
const doc = new Document();
const comment1 = doc.createComment('Comment 1');
const html = doc.createElement('html');
const comment2 = doc.createComment('Comment 2');
doc.replaceChildren(comment1, html, comment2);
testing.expectEqual(3, doc.childNodes.length);
testing.expectEqual('#comment', doc.firstChild.nodeName);
testing.expectEqual('Comment 1', doc.firstChild.textContent);
testing.expectEqual('html', doc.childNodes[1].nodeName);
testing.expectEqual('#comment', doc.lastChild.nodeName);
testing.expectEqual('Comment 2', doc.lastChild.textContent);
}
</script>
<script id=replace_with_empty>
{
const doc = new Document();
// First add some content
const div = doc.createElement('div');
doc.replaceChildren(div);
testing.expectEqual(1, doc.childNodes.length);
// Now replace with nothing
doc.replaceChildren();
testing.expectEqual(0, doc.childNodes.length);
testing.expectEqual(null, doc.firstChild);
testing.expectEqual(null, doc.lastChild);
}
</script>
<script id=replace_removes_old_children>
{
const doc = new Document();
const comment1 = doc.createComment('old');
doc.replaceChildren(comment1);
testing.expectEqual(1, doc.childNodes.length);
testing.expectEqual(doc, comment1.parentNode);
const html = doc.createElement('html');
html.id = 'new';
doc.replaceChildren(html);
// Old child should be removed
testing.expectEqual(null, comment1.parentNode);
testing.expectEqual(1, doc.childNodes.length);
testing.expectEqual('new', doc.firstChild.id);
}
</script>
<script id=replace_with_document_fragment_valid>
{
const doc = new Document();
const fragment = doc.createDocumentFragment();
const html = doc.createElement('html');
const comment = doc.createComment('comment');
fragment.appendChild(comment);
fragment.appendChild(html);
doc.replaceChildren(fragment);
// Fragment contents should be moved
testing.expectEqual(2, doc.childNodes.length);
testing.expectEqual('#comment', doc.firstChild.nodeName);
testing.expectEqual('html', doc.lastChild.nodeName);
// Fragment should be empty now
testing.expectEqual(0, fragment.childNodes.length);
}
</script>
<script id=replace_maintains_child_order>
{
const doc = new Document();
const nodes = [];
// Document can have: comment, processing instruction, doctype, element
nodes.push(doc.createComment('comment'));
nodes.push(doc.createElement('html'));
doc.replaceChildren(...nodes);
testing.expectEqual(2, doc.childNodes.length);
testing.expectEqual('#comment', doc.childNodes[0].nodeName);
testing.expectEqual('html', doc.childNodes[1].nodeName);
}
</script>
<script id=replace_with_nested_structure>
{
const doc = new Document();
const outer = doc.createElement('html');
outer.id = 'outer';
const middle = doc.createElement('body');
middle.id = 'middle';
const inner = doc.createElement('span');
inner.id = 'inner';
inner.textContent = 'Nested';
middle.appendChild(inner);
outer.appendChild(middle);
doc.replaceChildren(outer);
testing.expectEqual(1, doc.childNodes.length);
testing.expectEqual('outer', doc.firstChild.id);
const foundInner = doc.getElementById('inner');
testing.expectEqual(inner, foundInner);
testing.expectEqual('Nested', foundInner.textContent);
}
</script>
<script id=consecutive_replaces>
{
const doc = new Document();
const html1 = doc.createElement('html');
html1.id = 'first-replace';
doc.replaceChildren(html1);
testing.expectEqual('first-replace', doc.firstChild.id);
// Replace element with comments
const comment = doc.createComment('in between');
doc.replaceChildren(comment);
testing.expectEqual(1, doc.childNodes.length);
testing.expectEqual('#comment', doc.firstChild.nodeName);
// Replace comments with new element
const html2 = doc.createElement('html');
html2.id = 'second-replace';
doc.replaceChildren(html2);
testing.expectEqual('second-replace', doc.firstChild.id);
testing.expectEqual(1, doc.childNodes.length);
// First element should no longer be in document
testing.expectEqual(null, html1.parentNode);
testing.expectEqual(null, comment.parentNode);
}
</script>
<script id=replace_with_comments_only>
{
const doc = new Document();
const comment1 = doc.createComment('First');
const comment2 = doc.createComment('Second');
doc.replaceChildren(comment1, comment2);
testing.expectEqual(2, doc.childNodes.length);
testing.expectEqual('#comment', doc.firstChild.nodeName);
testing.expectEqual('First', doc.firstChild.textContent);
testing.expectEqual('#comment', doc.lastChild.nodeName);
testing.expectEqual('Second', doc.lastChild.textContent);
}
</script>
<script id=error_fragment_with_text>
{
// DocumentFragment with text should fail when inserted into Document
const doc = new Document();
const fragment = doc.createDocumentFragment();
fragment.appendChild(doc.createTextNode('text'));
fragment.appendChild(doc.createElement('html'));
testing.expectError('HierarchyRequest', () => {
doc.replaceChildren(fragment);
});
}
</script>
<script id=append_valid_nodes>
{
const doc = new Document();
const comment = doc.createComment('test');
const html = doc.createElement('html');
doc.append(comment);
testing.expectEqual(1, doc.childNodes.length);
doc.append(html);
testing.expectEqual(2, doc.childNodes.length);
testing.expectEqual('#comment', doc.firstChild.nodeName);
testing.expectEqual('html', doc.lastChild.nodeName);
}
</script>
<script id=prepend_valid_nodes>
{
const doc = new Document();
const html = doc.createElement('html');
const comment = doc.createComment('test');
doc.prepend(html);
testing.expectEqual(1, doc.childNodes.length);
doc.prepend(comment);
testing.expectEqual(2, doc.childNodes.length);
testing.expectEqual('#comment', doc.firstChild.nodeName);
testing.expectEqual('html', doc.lastChild.nodeName);
}
</script>

View File

@@ -168,7 +168,7 @@
const root = doc.documentElement;
testing.expectEqual(true, root !== null);
// TODO: XML documents should preserve case, but we currently uppercase
testing.expectEqual('ROOT', root.tagName);
testing.expectEqual('root', root.tagName);
}
</script>
@@ -206,10 +206,9 @@
const doc = impl.createDocument('http://example.com', 'prefix:localName', null);
const root = doc.documentElement;
// TODO: XML documents should preserve case, but we currently uppercase
testing.expectEqual('prefix:LOCALNAME', root.tagName);
// TODO: Custom namespaces are being overridden to XHTML namespace
testing.expectEqual('http://www.w3.org/1999/xhtml', root.namespaceURI);
testing.expectEqual('prefix:localName', root.tagName);
// TODO: Custom namespaces are being replaced with an empty value
testing.expectEqual('http://lightpanda.io/unsupported/namespace', root.namespaceURI);
}
</script>
@@ -224,8 +223,7 @@
doc.documentElement.appendChild(child);
testing.expectEqual(1, doc.documentElement.childNodes.length);
// TODO: XML documents should preserve case, but we currently uppercase
testing.expectEqual('CHILD', doc.documentElement.firstChild.tagName);
testing.expectEqual('child', doc.documentElement.firstChild.tagName);
testing.expectEqual('Test', doc.documentElement.firstChild.textContent);
}
</script>

View File

@@ -107,19 +107,6 @@
}
</script>
<script id=unsupportedMimeType>
{
const parser = new DOMParser();
// Should throw an error for unsupported MIME types
testing.withError((err) => {
testing.expectEqual('NotSupported', err.message);
}, () => {
parser.parseFromString('<div>test</div>', 'application/xml');
});
}
</script>
<script id=getElementById>
{
const doc = new DOMParser().parseFromString('<div id="new-node">new-node</div>', 'text/html');
@@ -244,3 +231,161 @@
testing.expectEqual('<html><head></head><body>spice</body></html>', new DOMParser().parseFromString('spice', "text/html").documentElement.outerHTML);
testing.expectEqual('<html><head></head><body></body></html>', new DOMParser().parseFromString('<html></html>', "text/html").documentElement.outerHTML);
</script>
<script id=parse-xml>
{
const sampleXML = `<?xml version="1.0"?>
<catalog>
<book id="bk101">
<author>Gambardella, Matthew</author>
<title>XML Developer's Guide</title>
<genre>Computer</genre>
<price>44.95</price>
<publish_date>2000-10-01</publish_date>
<description>An in-depth look at creating applications
with XML.</description>
</book>
<book id="bk102">
<author>Ralls, Kim</author>
<title>Midnight Rain</title>
<genre>Fantasy</genre>
<price>5.95</price>
<publish_date>2000-12-16</publish_date>
<description>A former architect battles corporate zombies,
an evil sorceress, and her own childhood to become queen
of the world.</description>
</book>
<book id="bk103">
<author>Corets, Eva</author>
<title>Maeve Ascendant</title>
<genre>Fantasy</genre>
<price>5.95</price>
<publish_date>2000-11-17</publish_date>
<description>After the collapse of a nanotechnology
society in England, the young survivors lay the
foundation for a new society.</description>
</book>
<book id="bk104">
<author>Corets, Eva</author>
<title>Oberon's Legacy</title>
<genre>Fantasy</genre>
<price>5.95</price>
<publish_date>2001-03-10</publish_date>
<description>In post-apocalypse England, the mysterious
agent known only as Oberon helps to create a new life
for the inhabitants of London. Sequel to Maeve
Ascendant.</description>
</book>
<book id="bk105">
<author>Corets, Eva</author>
<title>The Sundered Grail</title>
<genre>Fantasy</genre>
<price>5.95</price>
<publish_date>2001-09-10</publish_date>
<description>The two daughters of Maeve, half-sisters,
battle one another for control of England. Sequel to
Oberon's Legacy.</description>
</book>
<book id="bk106">
<author>Randall, Cynthia</author>
<title>Lover Birds</title>
<genre>Romance</genre>
<price>4.95</price>
<publish_date>2000-09-02</publish_date>
<description>When Carla meets Paul at an ornithology
conference, tempers fly as feathers get ruffled.</description>
</book>
<book id="bk107">
<author>Thurman, Paula</author>
<title>Splish Splash</title>
<genre>Romance</genre>
<price>4.95</price>
<publish_date>2000-11-02</publish_date>
<description>A deep sea diver finds true love twenty
thousand leagues beneath the sea.</description>
</book>
<book id="bk108">
<author>Knorr, Stefan</author>
<title>Creepy Crawlies</title>
<genre>Horror</genre>
<price>4.95</price>
<publish_date>2000-12-06</publish_date>
<description>An anthology of horror stories about roaches,
centipedes, scorpions and other insects.</description>
</book>
<book id="bk109">
<author>Kress, Peter</author>
<title>Paradox Lost</title>
<genre>Science Fiction</genre>
<price>6.95</price>
<publish_date>2000-11-02</publish_date>
<description>After an inadvertant trip through a Heisenberg
Uncertainty Device, James Salway discovers the problems
of being quantum.</description>
</book>
<book id="bk110">
<author>O'Brien, Tim</author>
<title>Microsoft .NET: The Programming Bible</title>
<genre>Computer</genre>
<price>36.95</price>
<publish_date>2000-12-09</publish_date>
<description>Microsoft's .NET initiative is explored in
detail in this deep programmer's reference.</description>
</book>
<book id="bk111">
<author>O'Brien, Tim</author>
<title>MSXML3: A Comprehensive Guide</title>
<genre>Computer</genre>
<price>36.95</price>
<publish_date>2000-12-01</publish_date>
<description>The Microsoft MSXML3 parser is covered in
detail, with attention to XML DOM interfaces, XSLT processing,
SAX and more.</description>
</book>
<book id="bk112">
<author>Galos, Mike</author>
<title>Visual Studio 7: A Comprehensive Guide</title>
<genre>Computer</genre>
<price>49.95</price>
<publish_date>2001-04-16</publish_date>
<description>Microsoft Visual Studio 7 is explored in depth,
looking at how Visual Basic, Visual C++, C#, and ASP+ are
integrated into a comprehensive development
environment.</description>
</book>
</catalog>`;
const parser = new DOMParser();
const mimes = [
"text/xml",
"application/xml",
"application/xhtml+xml",
"image/svg+xml",
];
for (const mime of mimes) {
const doc = parser.parseFromString(sampleXML, mime);
const { firstChild: { childNodes, children: collection, tagName }, children } = doc;
// doc.
testing.expectEqual(true, doc instanceof XMLDocument);
testing.expectEqual(1, children.length);
// firstChild.
// TODO: Modern browsers expect this in lowercase.
testing.expectEqual("catalog", tagName);
testing.expectEqual(25, childNodes.length);
testing.expectEqual(12, collection.length);
// Check children of first child.
for (let i = 0; i < collection.length; i++) {
const {children: elements, id} = collection.item(i);
testing.expectEqual("bk" + (100 + i + 1), id);
// TODO: Modern browsers expect these in lowercase.
testing.expectEqual("author", elements.item(0).tagName);
testing.expectEqual("title", elements.item(1).tagName);
testing.expectEqual("genre", elements.item(2).tagName);
testing.expectEqual("price", elements.item(3).tagName);
testing.expectEqual("publish_date", elements.item(4).tagName);
testing.expectEqual("description", elements.item(5).tagName);
}
}
}
</script>

View File

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

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id="test">first</div>
<div id="test">second</div>
<script id=duplicateIds>
const first = document.getElementById('test');
testing.expectEqual('first', first.textContent);
first.remove();
const second = document.getElementById('test');
testing.expectEqual('second', second.textContent);
// second.remove();
// testing.expectEqual(null, document.getElementById('test'));
</script>

View File

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

View File

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

View File

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

View File

@@ -238,6 +238,15 @@
testing.expectEqual('[object HTMLAudioElement]', audio.toString());
testing.expectEqual(true, audio.paused);
}
// Create with `Audio` constructor.
{
const audio = new Audio();
testing.expectEqual(true, audio instanceof HTMLAudioElement);
testing.expectEqual("[object HTMLAudioElement]", audio.toString());
testing.expectEqual(true, audio.paused);
testing.expectEqual("auto", audio.getAttribute("preload"));
}
</script>
<script id="create_video_element">

View File

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

View File

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

View File

@@ -42,6 +42,34 @@
testing.expectEqual('initial text', $('#textarea1').defaultValue)
</script>
<script id="defaultValue_set">
{
const textarea = document.createElement('textarea')
testing.expectEqual('', textarea.defaultValue)
testing.expectEqual('', textarea.value)
// Setting defaultValue should update the text content
textarea.defaultValue = 'new default'
testing.expectEqual('new default', textarea.defaultValue)
testing.expectEqual('new default', textarea.value)
testing.expectEqual('new default', textarea.textContent)
// Setting value should not affect defaultValue
textarea.value = 'user input'
testing.expectEqual('new default', textarea.defaultValue)
testing.expectEqual('user input', textarea.value)
// Test setting defaultValue on element that already has content
const textarea2 = document.createElement('textarea')
textarea2.textContent = 'initial content'
testing.expectEqual('initial content', textarea2.defaultValue)
textarea2.defaultValue = 'modified default'
testing.expectEqual('modified default', textarea2.defaultValue)
testing.expectEqual('modified default', textarea2.textContent)
}
</script>
<script id="disabled_initial">
testing.expectEqual(false, $('#textarea1').disabled)
testing.expectEqual(true, $('#textarea3').disabled)
@@ -149,3 +177,93 @@
testing.expectFalse(textarea.outerHTML.includes('required'))
}
</script>
<script id="clone_basic">
{
const original = document.createElement('textarea')
original.defaultValue = 'default text'
testing.expectEqual('default text', original.value)
// Change the value
original.value = 'user modified'
testing.expectEqual('user modified', original.value)
testing.expectEqual('default text', original.defaultValue)
// Clone the textarea
const clone = original.cloneNode(true)
// Clone should have the runtime value copied
testing.expectEqual('user modified', clone.value)
testing.expectEqual('default text', clone.defaultValue)
}
</script>
<script id="clone_preserves_user_changes">
{
// Create a fresh element to avoid interfering with other tests
const original = document.createElement('textarea')
original.textContent = 'initial text'
testing.expectEqual('initial text', original.defaultValue)
testing.expectEqual('initial text', original.value)
// User modifies the value
original.value = 'user typed this'
testing.expectEqual('user typed this', original.value)
testing.expectEqual('initial text', original.defaultValue)
// Clone should preserve the user's changes
const clone = original.cloneNode(true)
testing.expectEqual('user typed this', clone.value)
testing.expectEqual('initial text', clone.defaultValue)
}
</script>
<script id="clone_empty_textarea">
{
const original = document.createElement('textarea')
testing.expectEqual('', original.value)
original.value = 'some content'
const clone = original.cloneNode(true)
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,21 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id=d1>hello <em>world</em></div>
<script id=outerHTML>
const d1 = $('#d1');
testing.expectEqual('<div id=\"d1\">hello <em>world</em></div>', d1.outerHTML);
d1.outerHTML = '<p id=p1>spice</p>';
// setting outerHTML doesn't update what d1 points to
testing.expectEqual('<div id="d1">hello <em>world</em></div>', d1.outerHTML);
// but it does update the document
testing.expectEqual(null, document.getElementById('d1'));
testing.expectEqual(true, document.getElementById('p1') != null);
testing.expectEqual('<p id="p1">spice</p>', document.getElementById('p1').outerHTML);
// testing.expectEqual(true, document.body.outerHTML.replaceAll(/\n/g, '').startsWith('<body><p id="p1">spice</p><script id="outerHTML">'));
// document.getElementById('p1').outerHTML = '';
// testing.expectEqual(null, document.getElementById('p1'));
// testing.expectEqual(true, document.body.outerHTML.replaceAll(/\n/g, '').startsWith('<body><script id="outerHTML">'));
</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,53 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="removeListenerDuringDispatch">
const target = document.createElement("div");
let listener1Called = 0;
let listener2Called = 0;
let listener3Called = 0;
function listener1() {
listener1Called++;
console.warn("listener1 called, removing listener2 and adding listener3");
target.removeEventListener("foo", listener2);
target.addEventListener("foo", listener3);
}
function listener2() {
listener2Called++;
console.warn("listener2 called (SHOULD NOT HAPPEN)");
}
function listener3() {
listener3Called++;
console.warn("listener3 called (SHOULD NOT HAPPEN IN FIRST DISPATCH)");
}
target.addEventListener("foo", listener1);
target.addEventListener("foo", listener2);
console.warn("Dispatching first event");
target.dispatchEvent(new Event("foo"));
console.warn("After first dispatch:");
console.warn(" listener1Called:", listener1Called);
console.warn(" listener2Called:", listener2Called);
console.warn(" listener3Called:", listener3Called);
testing.expectEqual(1, listener1Called);
testing.expectEqual(0, listener2Called);
testing.expectEqual(0, listener3Called);
console.warn("Dispatching second event");
target.dispatchEvent(new Event("foo"));
console.warn("After second dispatch:");
console.warn(" listener1Called:", listener1Called);
console.warn(" listener2Called:", listener2Called);
console.warn(" listener3Called:", listener3Called);
testing.expectEqual(2, listener1Called);
testing.expectEqual(0, listener2Called);
testing.expectEqual(1, listener3Called);
</script>

View File

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

View File

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

View File

@@ -630,3 +630,8 @@
let bubbledEvent = new Event('bubble', {bubbles: true});
testing.expectEqual(false, bubbledEvent.isTrusted);
</script>
<script id=emptyMessageEvent>
// https://github.com/lightpanda-io/browser/pull/1316
testing.expectError('TypeError', () => MessageEvent(''));
</script>

View File

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

View File

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

View File

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

View File

@@ -112,3 +112,28 @@
});
});
</script>
<script id="microtask_access_to_records">
testing.async(async () => {
let savedRecords;
const promise = new Promise((resolve) => {
const element = document.createElement('div');
const observer = new MutationObserver((records) => {
// Save the records array itself
savedRecords = records;
resolve();
observer.disconnect();
});
observer.observe(element, { attributes: true });
element.setAttribute('test', 'value');
});
await promise;
// Force arena reset by making a Zig call
document.getElementsByTagName('*');
testing.expectEqual(1, savedRecords.length);
testing.expectEqual('attributes', savedRecords[0].type);
testing.expectEqual('test', savedRecords[0].attributeName);
});
</script>

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

@@ -2,12 +2,12 @@
<script src="../testing.js"></script>
<script id=response>
let response = new Response("Hello, World!");
testing.expectEqual(200, response.status);
testing.expectEqual("", response.statusText);
testing.expectEqual(true, response.ok);
testing.expectEqual("", response.url);
testing.expectEqual(false, response.redirected);
// let response = new Response("Hello, World!");
// testing.expectEqual(200, response.status);
// testing.expectEqual("", response.statusText);
// testing.expectEqual(true, response.ok);
// testing.expectEqual("", response.url);
// testing.expectEqual(false, response.redirected);
let response2 = new Response("Error occurred", {
status: 404,
@@ -18,28 +18,29 @@
"Cache-Control": "no-cache"
}
});
testing.expectEqual(404, response2.status);
testing.expectEqual("Not Found", response2.statusText);
testing.expectEqual(false, response2.ok);
testing.expectEqual("text/plain", response2.headers.get("Content-Type"));
testing.expectEqual("test-value", response2.headers.get("X-Custom"));
testing.expectEqual(true, true);
// testing.expectEqual(404, response2.status);
// testing.expectEqual("Not Found", response2.statusText);
// testing.expectEqual(false, response2.ok);
// testing.expectEqual("text/plain", response2.headers);
// testing.expectEqual("test-value", response2.headers.get("X-Custom"));
testing.expectEqual("no-cache", response2.headers.get("cache-control"));
let response3 = new Response("Created", { status: 201, statusText: "Created" });
testing.expectEqual("basic", response3.type);
testing.expectEqual(201, response3.status);
testing.expectEqual("Created", response3.statusText);
testing.expectEqual(true, response3.ok);
// let response3 = new Response("Created", { status: 201, statusText: "Created" });
// testing.expectEqual("basic", response3.type);
// testing.expectEqual(201, response3.status);
// testing.expectEqual("Created", response3.statusText);
// testing.expectEqual(true, response3.ok);
let nullResponse = new Response(null);
testing.expectEqual(200, nullResponse.status);
testing.expectEqual("", nullResponse.statusText);
// let nullResponse = new Response(null);
// testing.expectEqual(200, nullResponse.status);
// testing.expectEqual("", nullResponse.statusText);
let emptyResponse = new Response("");
testing.expectEqual(200, emptyResponse.status);
// let emptyResponse = new Response("");
// testing.expectEqual(200, emptyResponse.status);
</script>
<script id=json>
<!-- <script id=json>
testing.async(async () => {
const json = await new Promise((resolve) => {
let response = new Response('[]');
@@ -48,3 +49,4 @@
testing.expectEqual([], json);
});
</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

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<body></body>
<script id="adoptNode">
const old = document.implementation.createHTMLDocument("");
const div = old.createElement("div");
div.appendChild(old.createTextNode("text"));
testing.expectEqual(old, div.ownerDocument);
testing.expectEqual(old, div.firstChild.ownerDocument);
document.body.appendChild(div);
testing.expectEqual(document, div.ownerDocument);
testing.expectEqual(document, div.firstChild.ownerDocument);
</script>

View File

@@ -37,4 +37,7 @@
testing.expectEqual(null, c2.parentNode);
assertChildren([c3, c4], d1)
assertChildren([], d2)
testing.expectEqual(c3, d1.replaceChild(c3, c3));
assertChildren([c3, c4], d1)
</script>

View File

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

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