558 Commits

Author SHA1 Message Date
Pierre Tachoire
e15295bdac Merge pull request #1560 from arrufat/dump-markdown
Add support for dumping output to markdown
2026-02-19 10:32:57 +01:00
Karl Seguin
4e1f96e09c Merge pull request #1597 from lightpanda-io/CSSStyleProperties_setNamed
add CSSStyleProperties array set support
2026-02-19 17:30:54 +08:00
Pierre Tachoire
fdd52c17d7 add CSSStyleProperties array set support 2026-02-19 09:52:27 +01:00
Karl Seguin
07cefd71df Merge pull request #1571 from lightpanda-io/nikneym/persisted-typed-arrays
Persisted typed arrays
2026-02-19 15:46:10 +08:00
Halil Durak
abab10b2cc move createTypedArray to Local 2026-02-19 10:03:04 +03:00
Karl Seguin
5fd95788f9 Merge pull request #1585 from egrs/focusin-focusout-events
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
dispatch focusin/focusout events with relatedTarget
2026-02-19 08:15:54 +08:00
Karl Seguin
bd29f168e0 Merge pull request #1590 from egrs/range-tostring-fix
fix Range.toString() for cross-container and element ranges
2026-02-19 08:08:25 +08:00
Karl Seguin
dc97e33cd6 Merge pull request #1591 from lightpanda-io/input_and_window_test
Remove duplicate window test
2026-02-19 07:59:31 +08:00
Karl Seguin
caf7cb07cd Remove duplicate window test
Re-enable some commented out input tests
2026-02-19 07:47:00 +08:00
Karl Seguin
ad5df53ee7 Merge pull request #1583 from egrs/window-htmlelement-input-props
add window stubs, HTMLElement hidden/tabIndex, input attribute reflections
2026-02-19 07:44:36 +08:00
Halil Durak
95920bf207 ArrayBufferRef(...).Global: consistent, persisted typed arrays 2026-02-18 21:43:19 +03:00
egrs
6700166841 fix Range.toString() for cross-container and element ranges
implement proper tree-walking in writeTextContent to handle all cases:
same-element containers, cross-container ranges, and comment exclusion.
uncomment ~800 lines of Range tests and add 5 new toString tests.
2026-02-18 16:25:34 +01:00
Karl Seguin
b8196cd06e Merge pull request #1588 from egrs/click-to-focus
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
click on focusable elements calls focus() with events
2026-02-18 23:12:21 +08:00
egrs
c28afbf193 address review feedback: move stubs test, inline bridge functions, catch unreachable 2026-02-18 15:40:59 +01:00
egrs
b2c030140c click on focusable elements calls focus() with events
handleClick() was setting _active_element directly, bypassing
Element.focus() — so no blur/focus events fired on click.
Now calls focus() for input, button, select, textarea, anchor.
2026-02-18 15:08:40 +01:00
Adrià Arrufat
92f131bbe4 Inline writeIndentation helper 2026-02-18 23:02:10 +09:00
Pierre Tachoire
deda53a842 Merge pull request #1353 from lightpanda-io/wp/mrdimidium/multicontext
Use thread per connection
2026-02-18 14:59:15 +01:00
Pierre Tachoire
5391854c82 Merge pull request #1586 from lightpanda-io/makefile-typo-fix
typo fix
2026-02-18 14:51:46 +01:00
Pierre Tachoire
e288bfbec4 typo fix 2026-02-18 14:50:43 +01:00
egrs
377fe5bc40 add comment on _active_element ordering constraint 2026-02-18 14:50:20 +01:00
Adrià Arrufat
d264ff2801 Use attributes for checkbox rendering 2026-02-18 22:48:46 +09:00
egrs
a21bb6b02d dispatch focusin/focusout events with relatedTarget
focus() and blur() now dispatch all four spec-required FocusEvents:
blur (no bubble) → focusout (bubbles) → focus (no bubble) → focusin (bubbles)

Each event carries the correct relatedTarget: the element gaining focus
for blur/focusout, and the element losing focus for focus/focusin.
All four events are composed per W3C spec.

Relates to #1161
2026-02-18 14:31:11 +01:00
egrs
07a87dfba7 fix tabIndex default for interactive elements per spec
interactive elements (input, button, a, select, textarea, iframe)
default to 0 when tabindex attribute is absent; others default to -1.
also add TODO for hidden "until-found" tristate.
2026-02-18 13:13:12 +01:00
egrs
9e4db89521 add window stubs, HTMLElement hidden/tabIndex, input attribute reflections
- Window: alert/confirm/prompt (no-op stubs), devicePixelRatio
- HTMLElement: hidden (boolean), tabIndex (integer)
- Input: placeholder, min, max, step, multiple, autocomplete
2026-02-18 13:04:12 +01:00
Karl Seguin
536d394e41 Merge pull request #1579 from egrs/style-element-sheet
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
return CSSStyleSheet from HTMLStyleElement.sheet
2026-02-18 19:59:38 +08:00
Karl Seguin
c0580c7ad0 Merge pull request #1581 from egrs/element-attribute-reflections
add attribute reflections for 8 HTML element types
2026-02-18 19:50:23 +08:00
egrs
488e72ef4e wire up CSSStyleSheet.ownerNode to the style element 2026-02-18 12:41:31 +01:00
Karl Seguin
01c224d301 Merge pull request #1580 from egrs/image-complete
add HTMLImageElement.complete property
2026-02-18 19:02:07 +08:00
egrs
eaf95a85a8 fix OL.type default and TableCell span clamping per spec
OL.type returns "1" (not "") when attribute absent.
TableCell.colSpan clamps to 1-1000, rowSpan to 0-65534.
2026-02-18 12:00:44 +01:00
Pierre Tachoire
ba1d084660 Merge pull request #1570 from egrs/media-play-pause-events
Dispatch play, pause, and emptied events from HTMLMediaElement
2026-02-18 11:46:28 +01:00
egrs
2e64c461c3 add attribute reflections for 8 HTML element types
Wire up missing IDL properties for HTMLTimeElement (dateTime),
HTMLLIElement (value), HTMLOListElement (start, reversed, type),
HTMLOptGroupElement (disabled, label), HTMLQuoteElement (cite),
HTMLTableCellElement (colSpan, rowSpan), HTMLLabelElement (htmlFor),
and HTMLFieldSetElement (disabled, name).
2026-02-18 11:36:58 +01:00
Adrià Arrufat
ce5dad722f Make --dump format optional and improve markdown rendering 2026-02-18 19:33:32 +09:00
egrs
7675feca91 revert _playing flag: playing event fires on every resume
Tested in Chrome (headless, --autoplay-policy=no-user-gesture-required):
playing fires on every pause-to-play transition, not just the first
time. The _playing flag was incorrectly suppressing this. Removed it
and updated tests to match verified Chrome behavior.
2026-02-18 10:49:27 +01:00
egrs
c66d74e135 clarify comment: images are in broken state, not fully fetched 2026-02-18 10:42:01 +01:00
egrs
54d6eed740 fix type check: case-insensitive, accept empty string, clear on disconnect
Per spec, valid type values are absent, empty string, or a
case-insensitive match for "text/css". Also clear cached sheet
when the element is disconnected or type becomes invalid.
2026-02-18 10:40:42 +01:00
Nikolay Govorov
dc4b75070d Increases the memory limit on CI :/ 2026-02-18 09:23:37 +00:00
egrs
830eb74725 track playing state: only dispatch playing on first start
Per review: playing event should only fire on first start, not
on every resume from pause. Add _playing field, reset on load().
2026-02-18 10:22:49 +01:00
Nikolay Govorov
4f21d8d7a8 Implement multi-cdp architecture 2026-02-18 09:22:31 +00:00
Nikolay Govorov
424deb8faf Run cdp client on dedicated thread 2026-02-18 09:22:29 +00:00
Nikolay Govorov
b4a40f1257 Move IO loops from Server to cdp Client 2026-02-18 09:22:27 +00:00
Nikolay Govorov
9296c10ca4 Use per-cdp connection HttpClient 2026-02-18 09:22:26 +00:00
Nikolay Govorov
fbe65cd542 Use std.Atomic.Value in Server instead of direct atomic operations 2026-02-18 09:22:24 +00:00
Nikolay Govorov
ccbb6e4789 Make ArenaPool, Robots and Env thread safety 2026-02-18 09:22:23 +00:00
Nikolay Govorov
d70f436304 Fix use-after-free in Fetch 2026-02-18 09:22:21 +00:00
Nikolay Govorov
16aaa8201c Drop unused config opts 2026-02-18 09:22:18 +00:00
Karl Seguin
acc1f2f3d7 Merge pull request #1578 from lightpanda-io/detach_attached_parsed_node
Detach attached nodes on appendBeforeSibling callback
2026-02-18 15:42:13 +08:00
egrs
433d254c70 add HTMLImageElement.complete property
Per spec, complete returns true when the image has no src, is fully
fetched, or is broken. Since this is a headless browser that does
not fetch images, complete always returns true.
2026-02-18 08:18:54 +01:00
egrs
ea4eebd2d6 return CSSStyleSheet from HTMLStyleElement.sheet
Per spec, connected style elements with type text/css should
return a CSSStyleSheet object. Previously always returned null.
The sheet is lazily created and cached on first access.
2026-02-18 08:11:08 +01:00
Karl Seguin
3c00a527dd Merge pull request #1574 from egrs/performance-observer-buffered
Support buffered option in PerformanceObserver.observe()
2026-02-18 14:48:15 +08:00
egrs
f72a354066 address review: clear marks before test, assert exactly 2 2026-02-18 07:15:17 +01:00
egrs
7c92e0e9ce address review: fix doc comment, skip buffered if already queued 2026-02-18 07:14:12 +01:00
Karl Seguin
4f6868728d Detach attached nodes on appendBeforeSibling callback
html5ever generally makes guarantees about nodes being parentless when
appending, but we've already seen 1 case where appendCallback receives a
connected node.

We're now seeing something in appendBeforeSiblingCallback, but we have a clearer
picture of how this is happening. In this case, it's via custom element
upgrading and the custom element constructor has already placed the node in
the document.

It's worth pointing, html5ever just has an opaque reference to our node. While
it guarantees that it will give us parent-less nodes, it doesn't actually know
anything about our nodes, or our node._parent. The guarantee is only from its
own point of view. There's nothing stopping us from giving a node a default
parent as soon as html5ever asks us to create a new node, in which case, the
node _will_ have a parent.
2026-02-18 10:52:51 +08:00
Adrià Arrufat
0ec4522f9e Update test with new --dump flag 2026-02-18 11:39:14 +09:00
Adrià Arrufat
c6e0c6d096 Simplify tests in markdown 2026-02-18 11:20:22 +09:00
Adrià Arrufat
dc0fb9ed8a Remove unused import in markdown 2026-02-18 11:04:12 +09:00
Adrià Arrufat
66d9eaee78 Simplify block element rendering in Markdown 2026-02-18 11:01:00 +09:00
Karl Seguin
3797272faf Merge pull request #1576 from lightpanda-io/navigation-is-event-target
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
Navigation is the EventTarget
2026-02-18 07:33:48 +08:00
Karl Seguin
682b302d04 Merge pull request #1573 from egrs/response-statustext
Populate Response.statusText for network responses
2026-02-18 07:28:55 +08:00
Karl Seguin
1de10f9b05 Merge pull request #1572 from egrs/intersection-observer-timestamp
Use performance.now() for IntersectionObserverEntry.time
2026-02-18 07:21:57 +08:00
Muki Kiboigo
89e38c34b8 remove NavigationEventTarget 2026-02-17 11:13:39 -08:00
Muki Kiboigo
246d17972c add standaloneEventTarget to the Factory 2026-02-17 11:13:27 -08:00
Pierre Tachoire
55a8b37ef8 Merge pull request #1575 from lightpanda-io/remove-merge-marker
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
remove merge marker from test file
2026-02-17 18:29:12 +01:00
Pierre Tachoire
445183001b remove merge marker from test file 2026-02-17 18:27:54 +01:00
egrs
ca9e2200da use async delivery for buffered performance observer entries
Per spec, buffered entries should be delivered via a queued task,
not synchronously. Extract scheduling logic into
schedulePerformanceObserverDelivery() and use it from both
notifyPerformanceObservers and the buffered observe path.
2026-02-17 18:16:42 +01:00
Pierre Tachoire
eba3f84c04 Merge pull request #1569 from egrs/cdp-cookie-size
add cookie size to CDP response
2026-02-17 17:57:47 +01:00
Pierre Tachoire
867e6a8f4b Merge pull request #1566 from egrs/mouseevent-buttons-property
implement MouseEvent.buttons property
2026-02-17 17:57:30 +01:00
egrs
df9779ec59 restrict buffered option to type-based observation per spec
The buffered option is only valid with the type option, not entryTypes,
per the Performance Observer specification.
2026-02-17 16:12:20 +01:00
egrs
1b71d1e46d fix playing event: only dispatch on paused-to-playing transition
Per MDN, playing fires "after playback is first started, and whenever
it is restarted." A second play() while already playing should be a
no-op. Both play and playing now only fire on the paused -> playing
transition.
2026-02-17 16:10:49 +01:00
egrs
0a58918f47 address review: conditional event dispatch and explicit options
- play event only fires when transitioning from paused to playing
- pause event only fires when transitioning from playing to paused
- playing event always fires on play() per spec
- explicitly set bubbles: false, cancelable: false on events
- updated tests to verify no duplicate events on repeated calls
2026-02-17 16:03:00 +01:00
egrs
afbd927fc0 support buffered option in PerformanceObserver.observe()
When buffered is true, deliver existing performance entries that match
the observer's interest immediately, per the Performance Observer spec.
2026-02-17 15:58:38 +01:00
egrs
2aa09ae18d populate Response.statusText for network responses
Fetch responses from the network now have their statusText set using
the HTTP status phrase (e.g. "OK", "Not Found"), matching the Fetch
spec and the existing XMLHttpRequest behavior.
2026-02-17 15:49:20 +01:00
egrs
09789b0b72 use performance.now() for IntersectionObserverEntry.time
The time field was hardcoded to 0.0. Now uses the Performance API
to provide a real DOMHighResTimeStamp, matching the spec.
2026-02-17 15:38:02 +01:00
Halil Durak
2426abd17a introduce persisted typed arrays 2026-02-17 16:35:42 +03:00
Pierre Tachoire
db4a97743f Merge pull request #1562 from lightpanda-io/robots-cdp-failure
dispatch .page_navigated event on page error callback and create HTML page
2026-02-17 14:17:44 +01:00
Pierre Tachoire
7ca98ed344 Merge pull request #1568 from lightpanda-io/invalid_cookie_samesite
protect against long invalid samesite cookie values
2026-02-17 14:10:24 +01:00
egrs
c9d3d17999 dispatch play, playing, pause, and emptied events from HTMLMediaElement
play(), pause(), and load() now fire the corresponding DOM events,
matching the HTMLMediaElement spec behavior.
2026-02-17 13:22:32 +01:00
egrs
628049cfd7 add cookie size to CDP response
Compute and include the cookie size field (name.len + value.len)
in Storage.getCookies and Network.getCookies CDP responses,
matching Chrome's behavior.
2026-02-17 13:08:02 +01:00
egrs
ae9a11da53 implement MouseEvent.buttons property
Add the `buttons` read-only property to MouseEvent as specified by
the W3C UI Events spec (unsigned short bitmask of currently pressed
buttons). Propagate the field through PointerEvent and WheelEvent
constructors which inherit from MouseEvent.
2026-02-17 12:52:44 +01:00
Karl Seguin
7e097482bc protect against long invalid samesite cookie values 2026-02-17 19:09:29 +08:00
Pierre Tachoire
df1b151587 Merge pull request #1567 from lightpanda-io/http_linefeed_only_ending
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
Support HTTP headers which are \n terminated (as opposed to \r\n).
2026-02-17 12:09:05 +01:00
Karl Seguin
45eb59a5aa Support HTTP headers which are \n terminated (as opposed to \r\n).
Looks like curl will accept these as valid headers, and won't normalize the
header, so we have to deal with either a 2-byte or 1-byte terminated header
2026-02-17 18:55:50 +08:00
Karl Seguin
687c17bbe2 Merge pull request #1557 from lightpanda-io/internal_field_caching
Add internal field caching (for window.document and window.console)
2026-02-17 18:25:51 +08:00
Pierre Tachoire
7505aec706 generate always an HTML on pageDoneCallback
Add also image support
2026-02-17 10:31:47 +01:00
Pierre Tachoire
c7b414492d add image content type detection into Mime 2026-02-17 10:30:47 +01:00
Pierre Tachoire
14b0095822 move page error HTML creation into pageDoneCallback
Now pageErrCllaback call pageDoneCallback to finalize the page.
2026-02-17 09:47:45 +01:00
Karl Seguin
a1256b46c8 Merge pull request #1553 from lightpanda-io/nikneym/image-data
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
Basic support for `ImageData`
2026-02-17 08:57:31 +08:00
Halil Durak
094270dff7 prefer snake case in enums 2026-02-17 02:48:30 +03:00
Halil Durak
d4e24dabc2 internal -> handle 2026-02-17 02:47:51 +03:00
Halil Durak
842df0d112 extern struct -> struct 2026-02-17 02:46:36 +03:00
Halil Durak
cfa9427d7c ImageData: make sure that width and height are not 0 2026-02-17 02:18:43 +03:00
Halil Durak
3c01e24f02 ImageData: remove unnecessary comments 2026-02-17 02:18:13 +03:00
Karl Seguin
22dbf63ff9 Merge pull request #1563 from lightpanda-io/wp/mrdimidium/fix-sighandler-rc
Fix race condition in sighandler
2026-02-17 07:08:39 +08:00
Karl Seguin
814f7394a0 Merge pull request #1556 from lightpanda-io/robots-perf
`robots.txt` performance improvements
2026-02-17 06:58:28 +08:00
Halil Durak
9a4cebaa1b ImageData: prefer new typed array type 2026-02-17 01:45:40 +03:00
Halil Durak
c30207ac63 introduce js.createTypedArray
A new way to create typed arrays that allows using the same memory.
2026-02-17 01:45:19 +03:00
Nikolay Govorov
77afbddb91 Fix race condition in sighandler 2026-02-16 21:28:29 +00:00
Nikolay Govorov
18feeabe15 Merge pull request #1561 from lightpanda-io/wp/mrdimidium/drop-logs-interceptors
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
Remove logs interceptors feature
2026-02-16 17:45:58 +00:00
Pierre Tachoire
c3811d3a14 Merge pull request #1559 from lightpanda-io/handle_undefined_symbol
handle undefined symbol in debug log
2026-02-16 18:32:50 +01:00
Pierre Tachoire
f20d6b551d Merge pull request #1558 from lightpanda-io/empty_array_buffer
don't try to create empty backing store data
2026-02-16 18:31:51 +01:00
Pierre Tachoire
311bcadacb create HTML page error on page error callback. 2026-02-16 18:17:07 +01:00
Pierre Tachoire
2189c8cd82 dispatch .page_navigated event on page error callback
When a CDP client navigates to a page and the page generates an error,
it blocks waiting for the .page_navigated event.

It currently happens w/ robots.txt denied page.
Example: https://httpbin.io/deny
2026-02-16 17:46:37 +01:00
Nikolay Govorov
6553bb8147 Remove los interceptors feature 2026-02-16 15:48:18 +00:00
Adrià Arrufat
dea492fd64 Unify dump flags into --dump <format> 2026-02-17 00:42:06 +09:00
Karl Seguin
00ab7f04fa handle undefined symbol in debug log 2026-02-16 23:41:04 +08:00
Adrià Arrufat
d3ba714aba Rename --dump flag to --html
--dump is still supported but deprecated
2026-02-17 00:35:15 +09:00
Adrià Arrufat
748b37f1d6 Rename --dump-markdown to --markdown 2026-02-17 00:21:10 +09:00
Karl Seguin
b83b188aff don't try to create empty backing store data 2026-02-16 23:18:25 +08:00
Karl Seguin
cfefa32603 Merge pull request #1555 from lightpanda-io/link_crossorigin
add link crossOrigin accessor
2026-02-16 22:56:50 +08:00
Karl Seguin
85d8db3ef9 Merge pull request #1554 from lightpanda-io/crypto_digest
Add SubtleCrypto.digest (used in nytimes)
2026-02-16 22:56:34 +08:00
Adrià Arrufat
3c14dbe382 Trim trailing whitespace in pre blocks in Markdown 2026-02-16 21:34:46 +09:00
Adrià Arrufat
b49b2af11f Stop escaping periods in markdown 2026-02-16 21:29:57 +09:00
Adrià Arrufat
425a36aa51 Add support for table rendering in Markdown 2026-02-16 21:24:24 +09:00
Adrià Arrufat
ec0b9de713 Add support for ordered lists in Markdown 2026-02-16 21:04:06 +09:00
Adrià Arrufat
9f13b14f6d Add support strikethrough and task lists in Markdown 2026-02-16 20:55:51 +09:00
Karl Seguin
01e83b45b5 Add internal field caching (for window.document and window.console)
This expands the caching capabilities which were first added in
https://github.com/lightpanda-io/browser/pull/1552

Internal field caching requires up-front memory, but is faster. It is currently
enabled for window.document and window.console - two very frequently accessed
values.

Implementations must correctly provide an internal field index, with
consideration for index 0 which may or may not be reserved for the type (it
depends on the type). comptime checks run to make sure this is correct, but it
would probably be nice to at least let them be declared in any order.

This commit also removes the special handling for loading the window. This used
to rely on the window not having any internal fields, but it now has them for
caching so it can't be detected that way. Instead, the window is loaded like any
other object. (But now we have to special case the initial window TAO creation
to make it behave like any other Zig instance).
2026-02-16 19:16:39 +08:00
Halil Durak
f80566e0cb ImageData: add a type entry in bridge.zig 2026-02-16 11:21:33 +03:00
Karl Seguin
42afacf0af add link crossOrigin accessor 2026-02-16 08:32:42 +08:00
Karl Seguin
2e61e7e682 Add SubtleCrypto.digest (used in nytimes) 2026-02-16 08:10:06 +08:00
Karl Seguin
3de9267ea7 Merge pull request #1552 from lightpanda-io/v8_private_cache
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
Add v8 private object cache + Node.childNodes caching
2026-02-16 06:58:14 +08:00
Karl Seguin
8c99d4fcd2 Merge pull request #1547 from lightpanda-io/more_events
add FocusEvent, TextEvent and WheelEvent
2026-02-16 06:58:03 +08:00
Adrià Arrufat
be4e6e5ba5 Escape special characters and handle whitespace in Markdown 2026-02-16 00:10:00 +09:00
Adrià Arrufat
1b5efea6eb Add --dump-markdown flag
Add a new module to handle HTML-to-Markdown conversion and
integrate it into the fetch command via a new CLI flag.
2026-02-15 23:18:01 +09:00
Halil Durak
6554f80fad ImageData: constructor can throw DOM exceptions 2026-02-15 16:59:02 +03:00
Halil Durak
2e8a9f809e ImageData: add test 2026-02-15 16:52:50 +03:00
Halil Durak
dc66032720 ImageData: initial support
We're missing fancy HDR range currently, though, most websites stick to sRGB.
2026-02-15 16:52:35 +03:00
Karl Seguin
c9433782d8 Merge pull request #1551 from lightpanda-io/nikneym/u8-clamped-array
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
`simpleZigValueToJs`: support `Uint8ClampedArray`
2026-02-15 19:55:11 +08:00
Karl Seguin
fef5586ff5 add FocusEvent, TextEvent and WheelEvent 2026-02-15 19:45:00 +08:00
Pierre Tachoire
1f4a2fd654 Merge pull request #1549 from lightpanda-io/script_header_assertion
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
Add more granular assertions
2026-02-15 11:52:26 +01:00
Pierre Tachoire
8243385af6 Merge pull request #1546 from lightpanda-io/document_createEvent
add more types to document.createEvent
2026-02-15 11:51:13 +01:00
Halil Durak
26ce9b2d4a Uint8ClampedArray: implement .copy variant 2026-02-15 12:12:45 +03:00
Halil Durak
119f3169e2 Uint8ClampedArray: update comment + change enum literals
I feel `ref` and `copy` is on-point on what's being actually happening.
2026-02-15 12:11:44 +03:00
Karl Seguin
16bd22ee01 improve comments 2026-02-15 17:05:37 +08:00
Karl Seguin
f4a5f73ab2 Add v8 private object cache + Node.childNodes caching
Very simply, this PR ensures that:

  div.childNodes === div.childNodes

Previously, each invocation of childNodes would return a distinct object. Not
just inefficient, but incorrect.

Where this gets more complicated is the how.

The simple way to do this would be to have an optional `_child_nodes` field in
Node. When it's called the first time, we load and return it and, on subsequent
calls we can return it from the field directly.

But we generally avoid this pattern for data that we don't expect to be called
often relative to the number of instances. A page with 20K nodes _might_ see
.childNodes called on 1% of those, so storing a pointer in Nodes which isn't
going to be used isn't particularly memory efficient.

Instead, we have (historically) opted to store this in a page-level map/lookup.
This is used extensively for various element properties, e.g. the page
_element_class_lists lookup.

I recently abandoned work on v8 property caching
(https://github.com/lightpanda-io/browser/pull/1511). But then I looked into the
performance on a specific website with _a lot_ of DOMRect creation and I started
to think about both caching and pure-v8 DOM objects. So this PR became a
two-birds with one stone kind of deal. It re-introduces caching as a means to
solve the childNodes correctness. This uses 1 specific type of caching mechanism,
hooking into a v8::object's Private data map, but the code should be easily
extendable to support a faster (but less memory efficient, depending on the use
case) option: internal fields.
2026-02-15 11:34:01 +08:00
Karl Seguin
e61a4564ea Merge pull request #1545 from lightpanda-io/node_list_index
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
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
NodeList index fix + Node.childNode fix
2026-02-15 09:32:28 +08:00
Karl Seguin
e72edee1f2 Merge pull request #1548 from lightpanda-io/HTMLDListElement
Add HTMLDListElement
2026-02-15 07:45:38 +08:00
Karl Seguin
e8c150fcac Merge pull request #1544 from lightpanda-io/caller_function
Re-arrange Caller
2026-02-15 07:45:25 +08:00
Halil Durak
52418932b1 simpleZigValueToJs: support Uint8ClampedArray
Needed this to implement `ImageData#data` getter. This works differently than other typed arrays since returned object can be mutated from both Zig and JS ends.
2026-02-15 02:03:11 +03:00
Karl Seguin
4f81cb9333 Add more granular assertions
Trying to see how the "ScriptManager.Header buffer" assertion is failing. Either
`headerCallback` is being called multiple times, or the script is corrupt. By
adding a similar assertion in various places, we can hopefully narrow (a) what's
going on and (b) what code is involved.

Also, switched the BufferPool from DoublyLinkedList to SinglyLinkedList. Was
just reviewing this code (to see if the buffer could possibly become corrupt)
and realized this could be switched.
2026-02-14 20:01:09 +08:00
Karl Seguin
db46f47b96 Add HTMLDListElement
Fix names for ol and ul elements
2026-02-14 17:11:29 +08:00
Karl Seguin
edfe5594ba add more types to document.createEvent 2026-02-14 15:09:46 +08:00
Karl Seguin
f25e972594 NodeList index fix + Node.childNode fix
An index out of range request to a nodelist , e.g. childNodes[1000] now properly
returns a error.NotHandled error, which is given to v8 as a non-intercepted
property.

When a ChildNode node list is created from Node.childNode, we store the *Node
rather than its children. ChildNode is meant to be live, so if the node's
children changes, we should capture that.
2026-02-14 14:51:33 +08:00
Karl Seguin
d5488bdd42 Re-arrange Caller
This is preparatory work for re-introducing property caching and pure v8 WebAPIs

It does 3 things:

1 - It removes the duplication of method calling we had in Accessors and
    Functions type briges.

2 - It flattens the method-call chain. It used to be some code in bridge, then
    method, then _method. Most of the code is now in the same place. This will
    be important since caching requires the raw js_value, which we previously
    didn't expose from _method. Now, it's just there.

3 - Caller used to do everything. Then we introduced Local and a lot of Caller
    methods didn't need caller itself, they just needed &self.local. While those
    methods remain in Caller.zig, they now take a *const Local directly and thus
    can be called without Caller, making them usable without a Caller.
2026-02-14 14:22:46 +08:00
Karl Seguin
bbff64bc96 Merge pull request #1543 from lightpanda-io/zig_fmt
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
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
zig fmt
2026-02-14 14:20:43 +08:00
Karl Seguin
635afefdeb Merge pull request #1540 from lightpanda-io/tao_v8_storage
Optimize tao storage in v8 object.
2026-02-14 14:14:51 +08:00
Karl Seguin
fd3e67a0b4 zig fmt 2026-02-14 14:06:25 +08:00
Karl Seguin
729a6021ee update v8 dep 2026-02-14 14:06:03 +08:00
Karl Seguin
309f254c2c Optimize toa storage in v8 object.
We're currently using Get/SetInternalField to store our toa instance in v8. This
appears to be meant for v8 data itself, as it participates in the GC's
referencing counting. This is a bit obvious by the fact that it expects a
v8::Data, we we're able to do by wrapping our toa into a v8::External.

The Get/SetAlignedPointerFromInternalField seem specifically designed for toa,
as it takes a (void *) (thus, not requiring the external wrapper) and, from what
I understand is more efficient (presumably because the GC ignores it).

Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/149
2026-02-14 14:04:25 +08:00
Karl Seguin
5c37f04d64 Merge pull request #1539 from lightpanda-io/range_set_validation
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
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Add missing validation to range setStart and setEnd
2026-02-14 07:03:27 +08:00
Karl Seguin
7c3dd8e852 Merge pull request #1538 from lightpanda-io/browser_arenas
Remove custom-arenas, use ArenaPool instead
2026-02-14 07:03:14 +08:00
Karl Seguin
66ddedbaf3 Merge pull request #1537 from lightpanda-io/input_click
Default behavior for input click (radio / checkbox).
2026-02-14 07:02:05 +08:00
Karl Seguin
7981b17897 Merge pull request #1541 from lightpanda-io/range_compare_boundary_points
Fix Range.compareBoundaryPoint
2026-02-14 07:01:52 +08:00
Pierre Tachoire
62137d47c8 Merge pull request #1533 from lightpanda-io/element_positions
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
Add various element position properties
2026-02-13 15:30:21 +01:00
Karl Seguin
e3b5437f61 fix test to match swapped (correct) implementation 2026-02-13 16:28:53 +08:00
Karl Seguin
934693924e Fix Range.compareBoundaryPoint
START_TO_END and END_TO_START had their logic swapped. This fixes the remaining
862 failing cases in dom/ranges/Range-compareBoundaryPoints.html
2026-02-13 15:06:17 +08:00
Muki Kiboigo
308fd92a46 chunking robot store hashing 2026-02-12 22:55:40 -08:00
Karl Seguin
da1eb71ad0 Add missing validation to Range.comparePoint
Also re-order the validation to match what  WPT expects. Fixes all cases in
dom/ranges/Range-comparePoint.html
2026-02-13 14:54:43 +08:00
Muki Kiboigo
576dbb7ce6 switch to iterative match solving 2026-02-12 22:54:35 -08:00
Muki Kiboigo
d0c381b3df partial compilation of robot rules 2026-02-12 22:32:29 -08:00
Muki Kiboigo
55178a81c6 skip empty disallow rules during robots parsing 2026-02-12 22:08:16 -08:00
Muki Kiboigo
249308380b no more longest match tracking in robots 2026-02-12 22:07:04 -08:00
Karl Seguin
d91bec08c3 Add missing validation to range setStart and setEnd
Fixes the remaining failing cases (400!) of WPT dom/ranges/Range-set.html
2026-02-13 13:47:26 +08:00
Karl Seguin
e23ef4b0be Remove custom-arenas, use ArenaPool instead
This removes the browser-specific arenas (session, transfer, page, call) in
favor of the arena pool.

This is a bit of a win-lose commit. It exists as (the last?) step before I can
really start working on frames. Frames will require their own "page" and "call"
arenas, so there isn't just 1 per browser now, but rather N, where N is the
number of frames + 1 page. This change was already done for Contexts when
ExecutionWorld was removed, and the idea is the same: making these units more
self contained so to support cases where we break out of the "1" model we
currently have (1 browser, 1 session, 1 page, 1 context, ...).

But it's a bit of a step backwards because the ArenaPool is dumb and just resets
everything to a single hard-coded (for now) value: 16KB. But in my mind, an
arena that's used for 1 thing (e.g. the page or call arenas) is more likely to
be well-sized for that specific role in the future, even on a different
page/navigate.

I think ultimately, we'll move to an ArenaPool that has different levels, e.g.
acquire() and acquireLarge() which can reset to different sizes, so that a page
arena can use acquireLarge() and retain a larger amount of memory between use.
2026-02-13 12:34:27 +08:00
Karl Seguin
6037521c49 Default behavior for input click (radio / checkbox).
This wasn't 100% intuitive to me. At the start of the event, the input is
immediately toggled. But at any point during dispatching, the default behavior
can be suppressed. So the state of the input's check during dispatching captures
the "intent" of the click. But it's possible for one listener to see that
input.checked == true even though, by the end of dispatching, input.checked ==
false because some other listener called preventDefault().

To support this, we need to capture the "current" state so that, if we need to
rollback, we can. For radio buttons, this "current" state includes capturing
the currently checked radio (if any).
2026-02-13 11:06:46 +08:00
Pierre Tachoire
a27fac3677 Merge pull request #1535 from lightpanda-io/wp/mrdimidium/ram-e2e-tests
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: fix path
2026-02-12 16:04:40 +01:00
Pierre Tachoire
21f2eb664e ci: fix path 2026-02-12 15:44:28 +01:00
Pierre Tachoire
81546ef4b0 Merge pull request #1534 from lightpanda-io/wp/mrdimidium/ram-e2e-tests 2026-02-12 13:32:15 +01:00
Karl Seguin
4b90c8fd45 Add various element position properties
clientTop, clientLeft, scrollTop, scrollLeft, scrollHeight, scrollWidth,
offsetTop, offsetLeft, offsetWidth, offsetHeight.

These are all dummy implementation that hook, as much as possible, into what
layout information we have.

Explicitly set scroll information is stored on the page.
2026-02-12 19:30:29 +08:00
Pierre Tachoire
c643fb8aac ci: fix var name 2026-02-12 12:07:15 +01:00
Pierre Tachoire
0cae6ceca3 Merge pull request #1510 from lightpanda-io/wp/mrdimidium/ram-e2e-tests
Use cgroups for RAM mesurement
2026-02-12 11:55:02 +01:00
Karl Seguin
5cde59b53c Merge pull request #1528 from lightpanda-io/remove_recurise_curl_calls
Remove potential recursive abort call in curl
2026-02-12 18:42:09 +08:00
Pierre Tachoire
7df67630af ci: add cg_mem_peak into the tracked results 2026-02-12 11:33:52 +01:00
Karl Seguin
0c89dca261 When _not_ in a libcurl callback, deinit the transfer normally 2026-02-12 18:29:35 +08:00
Pierre Tachoire
6b953b8793 ci: keep both vm and cg memory regression tests 2026-02-12 11:24:59 +01:00
Karl Seguin
0d1defcf27 Merge pull request #1522 from lightpanda-io/remove_page_reset
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
Remove Page.reset
2026-02-12 17:47:13 +08:00
Pierre Tachoire
c1db9c19b3 Merge pull request #1532 from lightpanda-io/document_scrollingElement
Add document.scrollingElement read-only property
2026-02-12 09:49:16 +01:00
Pierre Tachoire
95487755ed Merge pull request #1531 from lightpanda-io/dom_token_list_compat
Improve compliance of DOMTokenList
2026-02-12 09:49:09 +01:00
Pierre Tachoire
4813469659 Merge pull request #1530 from lightpanda-io/node_contains_null
Allow node.contains(null) (false), per spec
2026-02-12 09:48:14 +01:00
Karl Seguin
4dfd357c0b Merge pull request #1529 from lightpanda-io/get_elements_by_tag_name_ns
Get elements by tag name ns
2026-02-12 16:41:56 +08:00
Karl Seguin
4ca0486518 Add document.scrollingElement read-only property
From MDN: In standards mode, this is the root element of the document,
document.documentElement.

Easy enough.
2026-02-12 14:10:38 +08:00
Karl Seguin
b139c05960 Improve compliance of DOMTokenList
1 - Make element.classList settable
2 - On replace, validate in expected order
3 - On replace, fire mutation observer even if new == old
4 - On replace, handle duplicate values
2026-02-12 14:07:31 +08:00
Karl Seguin
3d32759030 Allow node.contains(null) (false), per spec 2026-02-12 12:39:07 +08:00
Karl Seguin
badfe39a3d Refactor common Document and Element methods into Node 2026-02-12 12:31:05 +08:00
Karl Seguin
060e2db351 Add getElementsByTagNameNS
Do what we can based on the ns and names that we currently have.

Also also, allow element names with non-ascii characters.

both changes are driven by WPT dom/nodes/case.html
2026-02-12 12:25:21 +08:00
Karl Seguin
ed802c0404 Remove potential recursive abort call in curl
Curl doesn't like recursive calls. For example, you can't call
curl_multi_remove_handle from within a dataCallback.

This specifically means that, as-is, transfer.abort() calls aren't safe to be
called during a libcurl callback. Consider this code:

```
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.onreadystatechange = (e) => {
  req.abort();
}
req.send();
```

onreadystatechange is triggered by network events, i.e. it executes in libcurl
callback. Thus, the above code fails to truly "abort" the request with
`curl_multi_remove_handle` error, saying it's a recursive call.

To solve this, transfer.abort() now sets an `aborted = true` flag. Callbacks can
now use this flag to signal to libcurl to stop the transfer.

A test was added which reproduced this issue, but this comes from:
https://github.com/lightpanda-io/browser/issues/1527  which I wasn't able to
reliably reproduce. I did see it happen regularly, just not always. It seems
like this commit fixes that issue.
2026-02-12 11:29:47 +08:00
Karl Seguin
5d8739bfb2 Merge pull request #1524 from lightpanda-io/trigger_inline_handlers
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
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
Trigger inline handlers
2026-02-12 07:51:55 +08:00
Karl Seguin
086faf44fc Trigger inline handlers
This is a follow up / fix to https://github.com/lightpanda-io/browser/pull/1487

In that PR we triggered a "load" event for special elements, and as part of that
we triggered both the "onload" attribute via dispatchWithFunction and normal
bubbling with dispatch.

This PR applies this change generically and holistically. For example, if an
"abort" event is raised, the "onabort" attribute will be generated for that
element. Importantly, this gets executed in the correct dispatch order and
respect event cancellation (stopPropagation and stopImmediatePropagation).
2026-02-12 07:38:12 +08:00
Karl Seguin
e5eaa90c61 Merge pull request #1523 from lightpanda-io/query_selector_edge
Support a few more selector edge cases
2026-02-12 07:37:36 +08:00
Karl Seguin
b24807ea29 Merge pull request #1525 from lightpanda-io/global_iterability
Ability to remove a type from the global's iterable list.
2026-02-12 07:36:25 +08:00
Karl Seguin
d68bae9bc2 Merge pull request #1526 from lightpanda-io/node_properties
Passes all test for WPT dom/nodes/Node-properties.htm
2026-02-12 07:36:13 +08:00
Halil Durak
b891fb4502 Merge pull request #1486 from lightpanda-io/nikneym/hash-map-change
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
Change hash generation of global event handlers
2026-02-11 16:55:07 +03:00
Halil Durak
ea69b3b4e3 wrap assertions with comptime if 2026-02-11 16:45:31 +03:00
Karl Seguin
23c8616ba5 Passes all test for WPT dom/nodes/Node-properties.htm
Documents created via DOMImplementation should not inherit the page.URL.

Add textContent for DocumentFragment

Fix creteElement for XML namespace when not explicitly specified
2026-02-11 21:20:16 +08:00
Karl Seguin
b25c91affd Fix a few more cases
From dom/nodes/ParentNode-querySelector-escapes.html
2026-02-11 21:11:27 +08:00
Karl Seguin
151cefe0ec Ability to remove a type from the global's iterable list.
Some types, like AbortController, shouldn't be iterable on the window. This
commit (a) adds the ability to control this in the snapshot, and sets the
iterability based on the dom/interface-objects.html wpt test list.
2026-02-11 20:53:13 +08:00
Karl Seguin
3412ff94bc Support a few more selector edge cases
Trailing escape sequence (https://github.com/lightpanda-io/browser/issues/1515)
and tags started with non-ascii letters.
2026-02-11 14:37:20 +08:00
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
14112ed294 Remove Page.reset
Page.reset exists for 1 use case: multiple calls to the Page.navigate CDP
method. At an extreme, something like this in puppeteer:

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

Rather than handling this generically in Page, we now handle this case
specifically at the CDP layer. If the page isn't in its initial load state,
i.e. page._load_state != .waiting, then we reload the page from the session.

For reloading, my initial inclination was to do session.removePage then
session.createPage(). This behavior still seems potentially correct to me, but
compared to our `reset`, this would trigger extra notifications, namely:

self.notification.dispatch(.page_remove, .{});

and

self.notification.dispatch(.page_created, page);

Bacause of https://github.com/lightpanda-io/browser/pull/1265/ I guess that
could have side effects. So, to keep the behavior as close to the current
"reset", a new `session.replacePage()` has been added which behaves a lot like
removePage + createPage, but without the notifications being sent.

While I generally think this is just cleaner, this was largely driven by some
planning for frame support. The entity for a Frame will share a lot with the
Page (we'll extract that logic), so simplifying the Page, especially around
initialization, helps simplify frame support.
2026-02-11 13:53:49 +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
3e1909b645 ci: use cgroups with user's permissions 2026-02-10 18:43:31 +01: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
Nikolay Govorov
a4b1fbd6ee Use cgroups for RAM mesurement 2026-02-10 01:34:17 +00: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
Halil Durak
6d2ef9be5d change hash generation of global event handlers
Small change inspired from #1475.
2026-02-06 22:35:35 +03:00
Pierre Tachoire
57fb167a9c add console test 2026-02-06 19:19:36 +01:00
Pierre Tachoire
0406bba384 fix Element.replaceWith crash when self replacing
Fix a crash with the WPT test dom/nodes/ChildNode-replaceWith.html
When we call `div.replaceWith(div);` we don't want to touch self at all.
2026-02-06 18:25:04 +01:00
Karl Seguin
7c4c80fe4a Merge pull request #1478 from lightpanda-io/filter_jquery_unknown_object_property
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Filter out jquery* unknown object property
2026-02-06 10:54:53 +08:00
Karl Seguin
bfb267e164 Filter out jquery* unknown object property 2026-02-06 07:55:41 +08:00
Karl Seguin
a0720948a1 Merge pull request #1417 from lightpanda-io/observer_arenas
Leverage finalizers and ArenaPool in Intersction and Mutation Observer
2026-02-06 07:42:38 +08:00
Karl Seguin
9f00159a84 Merge pull request #1475 from lightpanda-io/event_lookup_include_type
Change the listener lookup from element, to (element,type).
2026-02-06 07:41:43 +08:00
Muki Kiboigo
34067a1d70 only use eqlIgnoreCase for RobotStore 2026-02-05 08:02:35 -08:00
Halil Durak
3f6917fdcb Merge pull request #1477 from lightpanda-io/nikneym/load-event-fix
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Create distinct `Event` objects for each `load` event
2026-02-05 17:01:30 +03:00
Halil Durak
c04a6e501e create distinct Event objects for each load event 2026-02-05 16:47:09 +03:00
Karl Seguin
661b564399 rename field element -> event_target 2026-02-05 20:26:43 +08:00
Halil Durak
761c103373 Merge pull request #1389 from lightpanda-io/nikneym/image-src-dispatch
`Image` & `Style`: Dispatch `load` event
2026-02-05 15:23:38 +03:00
Karl Seguin
f4bd9e3d24 Merge pull request #1476 from lightpanda-io/log_unknown_object_properties
Log unknown object properties in debug builds
2026-02-05 19:25:17 +08:00
Karl Seguin
b9ddac878c Log unknown object properties in debug builds
We currently log any unknown property access on the global (window). This has
proven pretty useful when debugging, often letting us know a type we're
completely missing.

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

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

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

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

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

remove `hasListener`

let `Scheduler` dispatch `load` event

Simulates async nature.

update test

free `args` when done

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

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

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

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

add `onload` getter/setter for `Image`

prefer `attributeChange` to run side-effects

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The original PR had:

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

But now the code is:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -56,8 +56,6 @@ jobs:
submodules: recursive
- uses: ./.github/actions/install
with:
mode: 'release'
- name: zig build release
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
@@ -124,10 +122,19 @@ jobs:
needs: zig-build-release
env:
MAX_MEMORY: 28000
MAX_AVG_DURATION: 23
MAX_VmHWM: 28000 # 28MB (KB)
MAX_CG_PEAK: 8000 # 8MB (KB)
MAX_AVG_DURATION: 17
LIGHTPANDA_DISABLE_TELEMETRY: true
# How to give cgroups access to the user actions-runner on the host:
# $ sudo apt install cgroup-tools
# $ sudo chmod o+w /sys/fs/cgroup/cgroup.procs
# $ sudo mkdir -p /sys/fs/cgroup/actions-runner
# $ sudo chown -R actions-runner:actions-runner /sys/fs/cgroup/actions-runner
CG_ROOT: /sys/fs/cgroup
CG: actions-runner/lpd_${{ github.run_id }}_${{ github.run_attempt }}
# use a self host runner.
runs-on: lpd-bench-hetzner
timeout-minutes: 15
@@ -152,22 +159,53 @@ jobs:
go run ws/main.go & echo $! > WS.pid
sleep 2
- name: run lightpanda in cgroup
run: |
if [ ! -f /sys/fs/cgroup/cgroup.controllers ]; then
echo "cgroup v2 not available: /sys/fs/cgroup/cgroup.controllers missing"
exit 1
fi
mkdir -p $CG_ROOT/$CG
cgexec -g memory:$CG ./lightpanda serve & echo $! > LPD.pid
sleep 2
- name: run puppeteer
run: |
./lightpanda serve & echo $! > LPD.pid
sleep 2
RUNS=100 npm run bench-puppeteer-cdp > puppeteer.out || exit 1
cat /proc/`cat LPD.pid`/status |grep VmHWM|grep -oP '\d+' > LPD.VmHWM
kill `cat LPD.pid`
PID=$(cat LPD.pid)
while kill -0 $PID 2>/dev/null; do
sleep 1
done
if [ ! -f $CG_ROOT/$CG/memory.peak ]; then
echo "memory.peak not available in $CG"
exit 1
fi
cat $CG_ROOT/$CG/memory.peak > LPD.cg_mem_peak
- name: puppeteer result
run: cat puppeteer.out
- name: memory regression
- name: cgroup memory regression
run: |
PEAK_BYTES=$(cat LPD.cg_mem_peak)
PEAK_KB=$((PEAK_BYTES / 1024))
echo "memory.peak_bytes=$PEAK_BYTES"
echo "memory.peak_kb=$PEAK_KB"
test "$PEAK_KB" -le "$MAX_CG_PEAK"
- name: virtual memory regression
run: |
export LPD_VmHWM=`cat LPD.VmHWM`
echo "Peak resident set size: $LPD_VmHWM"
test "$LPD_VmHWM" -le "$MAX_MEMORY"
test "$LPD_VmHWM" -le "$MAX_VmHWM"
- name: cleanup cgroup
run: rmdir $CG_ROOT/$CG
- name: duration regression
run: |
@@ -180,7 +218,8 @@ jobs:
export AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`
export TOTAL_DURATION=`cat puppeteer.out|grep 'total duration'|sed 's/total duration (ms) //'`
export LPD_VmHWM=`cat LPD.VmHWM`
echo "{\"duration_total\":${TOTAL_DURATION},\"duration_avg\":${AVG_DURATION},\"mem_peak\":${LPD_VmHWM}}" > bench.json
export LPD_CG_PEAK_KB=$(( $(cat LPD.cg_mem_peak) / 1024 ))
echo "{\"duration_total\":${TOTAL_DURATION},\"duration_avg\":${AVG_DURATION},\"mem_peak\":${LPD_VmHWM},\"cg_mem_peak\":${LPD_CG_PEAK_KB}}" > bench.json
cat bench.json
- name: run hyperfine

View File

@@ -12,8 +12,7 @@ on:
- main
paths:
- "build.zig"
- "src/**/*.zig"
- "src/*.zig"
- "src/**"
- "vendor/zig-js-runtime"
- ".github/**"
- "vendor/**"
@@ -38,6 +37,26 @@ on:
workflow_dispatch:
jobs:
zig-test-debug:
name: zig test using v8 in debug mode
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
with:
debug: true
- name: zig build test
run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test
zig-test:
name: zig test
timeout-minutes: 15

View File

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

View File

@@ -57,7 +57,7 @@ build-v8-snapshot:
## Build in release-fast mode
build: build-v8-snapshot
@printf "\033[36mBuilding (release safe)...\033[0m\n"
@printf "\033[36mBuilding (release fast)...\033[0m\n"
@$(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"

View File

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

View File

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

View File

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

24
flake.lock generated
View File

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

View File

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

101
src/ArenaPool.zig Normal file
View File

@@ -0,0 +1,101 @@
// 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),
mutex: std.Thread.Mutex = .{},
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 {
self.mutex.lock();
defer self.mutex.unlock();
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);
// Reset the arena before acquiring the lock to minimize lock hold time
_ = arena.reset(.{ .retain_with_limit = self.retain_bytes });
self.mutex.lock();
defer self.mutex.unlock();
const free_list_len = self.free_list_len;
if (free_list_len == self.free_list_max) {
arena.deinit();
self.entry_pool.destroy(entry);
return;
}
entry.next = self.free_list;
self.free_list_len = free_list_len + 1;
self.free_list = entry;
}
pub fn reset(_: *const ArenaPool, allocator: Allocator, retain: usize) void {
const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr));
_ = arena.reset(.{ .retain_with_limit = retain });
}

802
src/Config.zig Normal file
View File

@@ -0,0 +1,802 @@
// 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,
};
pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
// max message size
// +14 for max websocket payload overhead
// +140 for the max control packet that might be interleaved in a message
pub const CDP_MAX_MESSAGE_SIZE = 512 * 1024 + 14 + 140;
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 fn maxConnections(self: *const Config) u16 {
return switch (self.mode) {
.serve => |opts| opts.cdp_max_connections,
else => unreachable,
};
}
pub fn maxPendingConnections(self: *const Config) u31 {
return switch (self.mode) {
.serve => |opts| opts.cdp_max_pending_connections,
else => unreachable,
};
}
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,
cdp_max_connections: u16 = 16,
cdp_max_pending_connections: u16 = 128,
common: Common = .{},
};
pub const DumpFormat = enum {
html,
markdown,
};
pub const Fetch = struct {
url: [:0]const u8,
dump_mode: ?DumpFormat = null,
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 html https://lightpanda.io/
\\
\\Options:
\\--dump Dumps document to stdout.
\\ Argument must be 'html' or 'markdown'.
\\ Defaults to no dump.
\\
\\--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).
\\
\\--cdp_max_connections
\\ Maximum number of simultaneous CDP connections.
\\ Defaults to 16.
\\
\\--cdp_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, "--cdp_max_connections", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_connections" });
return error.InvalidArgument;
};
serve.cdp_max_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--cdp_max_connections", .err = err });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--cdp_max_pending_connections", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_pending_connections" });
return error.InvalidArgument;
};
serve.cdp_max_pending_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--cdp_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 dump_mode: ?DumpFormat = null;
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)) {
var peek_args = args.*;
if (peek_args.next()) |next_arg| {
if (std.meta.stringToEnum(DumpFormat, next_arg)) |mode| {
dump_mode = mode;
_ = args.next();
} else {
dump_mode = .html;
}
} else {
dump_mode = .html;
}
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_mode = dump_mode,
.strip = strip,
.common = common,
.withbase = withbase,
};
}
fn parseCommonArg(
allocator: Allocator,
opt: []const u8,
args: *std.process.ArgIterator,
common: *Common,
) !bool {
if (std.mem.eql(u8, "--insecure_disable_tls_host_verification", opt)) {
common.tls_verify_host = false;
return true;
}
if (std.mem.eql(u8, "--obey_robots", opt)) {
common.obey_robots = true;
return true;
}
if (std.mem.eql(u8, "--http_proxy", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_proxy" });
return error.InvalidArgument;
};
common.http_proxy = try allocator.dupeZ(u8, str);
return true;
}
if (std.mem.eql(u8, "--proxy_bearer_token", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--proxy_bearer_token" });
return error.InvalidArgument;
};
common.proxy_bearer_token = try allocator.dupeZ(u8, str);
return true;
}
if (std.mem.eql(u8, "--http_max_concurrent", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_concurrent" });
return error.InvalidArgument;
};
common.http_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_concurrent", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--http_max_host_open", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_host_open" });
return error.InvalidArgument;
};
common.http_max_host_open = std.fmt.parseInt(u8, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_host_open", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--http_connect_timeout", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_connect_timeout" });
return error.InvalidArgument;
};
common.http_connect_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_connect_timeout", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--http_timeout", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_timeout" });
return error.InvalidArgument;
};
common.http_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_timeout", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--http_max_response_size", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_response_size" });
return error.InvalidArgument;
};
common.http_max_response_size = std.fmt.parseInt(usize, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_response_size", .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--log_level", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--log_level" });
return error.InvalidArgument;
};
common.log_level = std.meta.stringToEnum(log.Level, str) orelse blk: {
if (std.mem.eql(u8, str, "error")) {
break :blk .err;
}
log.fatal(.app, "invalid option choice", .{ .arg = "--log_level", .value = str });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--log_format", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--log_format" });
return error.InvalidArgument;
};
common.log_format = std.meta.stringToEnum(log.Format, str) orelse {
log.fatal(.app, "invalid option choice", .{ .arg = "--log_format", .value = str });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--log_filter_scopes", opt)) {
if (builtin.mode != .Debug) {
log.fatal(.app, "experimental", .{ .help = "log scope filtering is only available in debug builds" });
return false;
}
const str = args.next() orelse {
// disables the default filters
common.log_filter_scopes = &.{};
return true;
};
var arr: std.ArrayList(log.Scope) = .empty;
var it = std.mem.splitScalar(u8, str, ',');
while (it.next()) |part| {
try arr.append(allocator, std.meta.stringToEnum(log.Scope, part) orelse {
log.fatal(.app, "invalid option choice", .{ .arg = "--log_filter_scopes", .value = part });
return false;
});
}
common.log_filter_scopes = arr.items;
return true;
}
if (std.mem.eql(u8, "--user_agent_suffix", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--user_agent_suffix" });
return error.InvalidArgument;
};
for (str) |c| {
if (!std.ascii.isPrint(c)) {
log.fatal(.app, "not printable character", .{ .arg = "--user_agent_suffix" });
return error.InvalidArgument;
}
}
common.user_agent_suffix = try allocator.dupe(u8, str);
return true;
}
return false;
}

View File

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

View File

@@ -28,23 +28,25 @@ const ArenaAllocator = std.heap.ArenaAllocator;
const log = @import("log.zig");
const App = @import("App.zig");
const Config = @import("Config.zig");
const CDP = @import("cdp/cdp.zig").CDP;
const MAX_HTTP_REQUEST_SIZE = 4096;
// max message size
// +14 for max websocket payload overhead
// +140 for the max control packet that might be interleaved in a message
const MAX_MESSAGE_SIZE = 512 * 1024 + 14 + 140;
const Http = @import("http/Http.zig");
const HttpClient = @import("http/Client.zig");
const Server = @This();
app: *App,
shutdown: bool = false,
shutdown: std.atomic.Value(bool) = .init(false),
allocator: Allocator,
client: ?posix.socket_t,
listener: ?posix.socket_t,
json_version_response: []const u8,
// Thread management
active_threads: std.atomic.Value(u32) = .init(0),
clients: std.ArrayList(*Client) = .{},
client_mutex: std.Thread.Mutex = .{},
clients_pool: std.heap.MemoryPool(Client),
pub fn init(app: *App, address: net.Address) !Server {
const allocator = app.allocator;
const json_version_response = try buildJSONVersionResponse(allocator, address);
@@ -52,19 +54,28 @@ pub fn init(app: *App, address: net.Address) !Server {
return .{
.app = app,
.client = null,
.listener = null,
.allocator = allocator,
.json_version_response = json_version_response,
.clients_pool = std.heap.MemoryPool(Client).init(app.allocator),
};
}
/// Interrupts the server so that main can complete normally and call all defer handlers.
pub fn stop(self: *Server) void {
if (@atomicRmw(bool, &self.shutdown, .Xchg, true, .monotonic)) {
if (self.shutdown.swap(true, .release)) {
return;
}
// Shutdown all active clients
{
self.client_mutex.lock();
defer self.client_mutex.unlock();
for (self.clients.items) |client| {
client.stop();
}
}
// Linux and BSD/macOS handle canceling a socket blocked on accept differently.
// For Linux, we use std.shutdown, which will cause accept to return error.SocketNotListening (EINVAL).
// For BSD, shutdown will return an error. Instead we call posix.close, which will result with error.ConnectionAborted (BADF).
@@ -81,17 +92,22 @@ pub fn stop(self: *Server) void {
}
pub fn deinit(self: *Server) void {
if (!self.shutdown.load(.acquire)) {
self.stop();
}
self.joinThreads();
if (self.listener) |listener| {
posix.close(listener);
self.listener = null;
}
// *if* server.run is running, we should really wait for it to return
// before existing from here.
self.clients.deinit(self.allocator);
self.clients_pool.deinit();
self.allocator.free(self.json_version_response);
}
pub fn run(self: *Server, address: net.Address, timeout_ms: u32) !void {
const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC;
const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK;
const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP);
self.listener = listener;
@@ -101,16 +117,20 @@ pub fn run(self: *Server, address: net.Address, timeout_ms: u32) !void {
}
try posix.bind(listener, &address.any, address.getOsSockLen());
try posix.listen(listener, 1);
try posix.listen(listener, self.app.config.maxPendingConnections());
log.info(.app, "server running", .{ .address = address });
while (!@atomicLoad(bool, &self.shutdown, .monotonic)) {
while (!self.shutdown.load(.acquire)) {
const socket = posix.accept(listener, null, null, posix.SOCK.NONBLOCK) catch |err| {
switch (err) {
error.SocketNotListening, error.ConnectionAborted => {
log.info(.app, "server stopped", .{});
break;
},
error.WouldBlock => {
std.Thread.sleep(10 * std.time.ns_per_ms);
continue;
},
else => {
log.err(.app, "CDP accept", .{ .err = err });
std.Thread.sleep(std.time.ns_per_s);
@@ -119,97 +139,121 @@ pub fn run(self: *Server, address: net.Address, timeout_ms: u32) !void {
}
};
self.client = socket;
defer if (self.client) |s| {
posix.close(s);
self.client = null;
};
if (log.enabled(.app, .info)) {
var client_address: std.net.Address = undefined;
var socklen: posix.socklen_t = @sizeOf(net.Address);
try std.posix.getsockname(socket, &client_address.any, &socklen);
log.info(.app, "client connected", .{ .ip = client_address });
}
self.readLoop(socket, timeout_ms) catch |err| {
log.err(.app, "CDP client loop", .{ .err = err });
self.spawnWorker(socket, timeout_ms) catch |err| {
log.err(.app, "CDP spawn", .{ .err = err });
posix.close(socket);
};
}
}
fn readLoop(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void {
// This shouldn't be necessary, but the Client is HUGE (> 512KB) because
// it has a large read buffer. I don't know why, but v8 crashes if this
// is on the stack (and I assume it's related to its size).
const client = try self.allocator.create(Client);
defer self.allocator.destroy(client);
fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void {
defer posix.close(socket);
client.* = try Client.init(socket, self);
// Client is HUGE (> 512KB) because it has a large read buffer.
// V8 crashes if this is on the stack (likely related to its size).
const client = self.getClient() catch |err| {
log.err(.app, "CDP client create", .{ .err = err });
return;
};
defer self.releaseClient(client);
client.* = Client.init(
socket,
self.allocator,
self.app,
self.json_version_response,
timeout_ms,
) catch |err| {
log.err(.app, "CDP client init", .{ .err = err });
return;
};
defer client.deinit();
var http = &self.app.http;
http.addCDPClient(.{
.socket = socket,
.ctx = client,
.blocking_read_start = Client.blockingReadStart,
.blocking_read = Client.blockingRead,
.blocking_read_end = Client.blockingReadStop,
});
defer http.removeCDPClient();
self.registerClient(client);
defer self.unregisterClient(client);
lp.assert(client.mode == .http, "Server.readLoop invalid mode", .{});
while (true) {
if (http.poll(timeout_ms) != .cdp_socket) {
log.info(.app, "CDP timeout", .{});
return;
}
if (client.readSocket() == false) {
return;
}
if (client.mode == .cdp) {
break; // switch to our CDP loop
}
// Check shutdown after registering to avoid missing stop() signal.
// If stop() already iterated over clients, this client won't receive stop()
// and would block joinThreads() indefinitely.
if (self.shutdown.load(.acquire)) {
return;
}
var cdp = &client.mode.cdp;
var last_message = timestamp(.monotonic);
var ms_remaining = timeout_ms;
while (true) {
switch (cdp.pageWait(ms_remaining)) {
.cdp_socket => {
if (client.readSocket() == false) {
return;
}
last_message = timestamp(.monotonic);
ms_remaining = timeout_ms;
},
.no_page => {
if (http.poll(ms_remaining) != .cdp_socket) {
log.info(.app, "CDP timeout", .{});
return;
}
if (client.readSocket() == false) {
return;
}
last_message = timestamp(.monotonic);
ms_remaining = timeout_ms;
},
.done => {
const elapsed = timestamp(.monotonic) - last_message;
if (elapsed > ms_remaining) {
log.info(.app, "CDP timeout", .{});
return;
}
ms_remaining -= @intCast(elapsed);
},
.navigate => unreachable, // must have been handled by the session
client.start();
}
fn getClient(self: *Server) !*Client {
self.client_mutex.lock();
defer self.client_mutex.unlock();
return self.clients_pool.create();
}
fn releaseClient(self: *Server, client: *Client) void {
self.client_mutex.lock();
defer self.client_mutex.unlock();
self.clients_pool.destroy(client);
}
fn registerClient(self: *Server, client: *Client) void {
self.client_mutex.lock();
defer self.client_mutex.unlock();
self.clients.append(self.allocator, client) catch {};
}
fn unregisterClient(self: *Server, client: *Client) void {
self.client_mutex.lock();
defer self.client_mutex.unlock();
for (self.clients.items, 0..) |c, i| {
if (c == client) {
_ = self.clients.swapRemove(i);
break;
}
}
}
fn spawnWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void {
if (self.shutdown.load(.acquire)) {
return error.ShuttingDown;
}
// Atomically increment active_threads only if below max_connections.
// Uses CAS loop to avoid race between checking the limit and incrementing.
//
// cmpxchgWeak may fail for two reasons:
// 1. Another thread changed the value (increment or decrement)
// 2. Spurious failure on some architectures (e.g. ARM)
//
// We use Weak instead of Strong because we need a retry loop anyway:
// if CAS fails because a thread finished (counter decreased), we should
// retry rather than return an error - there may now be room for a new connection.
//
// On failure, cmpxchgWeak returns the actual value, which we reuse to avoid
// an extra load on the next iteration.
const max_connections = self.app.config.maxConnections();
var current = self.active_threads.load(.monotonic);
while (current < max_connections) {
current = self.active_threads.cmpxchgWeak(current, current + 1, .monotonic, .monotonic) orelse break;
} else {
return error.MaxThreadsReached;
}
errdefer _ = self.active_threads.fetchSub(1, .monotonic);
const thread = try std.Thread.spawn(.{}, runWorker, .{ self, socket, timeout_ms });
thread.detach();
}
fn runWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) void {
defer _ = self.active_threads.fetchSub(1, .monotonic);
handleConnection(self, socket, timeout_ms);
}
fn joinThreads(self: *Server) void {
while (self.active_threads.load(.monotonic) > 0) {
std.Thread.sleep(10 * std.time.ns_per_ms);
}
}
// Handle exactly one TCP connection.
pub const Client = struct {
// The client is initially serving HTTP requests but, under normal circumstances
// should eventually be upgraded to a websocket connections
@@ -218,11 +262,15 @@ pub const Client = struct {
cdp: CDP,
},
server: *Server,
allocator: Allocator,
app: *App,
http: *HttpClient,
json_version_response: []const u8,
reader: Reader(true),
socket: posix.socket_t,
socket_flags: usize,
send_arena: ArenaAllocator,
timeout_ms: u32,
const EMPTY_PONG = [_]u8{ 138, 0 };
@@ -233,25 +281,49 @@ pub const Client = struct {
// "private-use" close codes must be from 4000-49999
const CLOSE_TIMEOUT = [_]u8{ 136, 2, 15, 160 }; // code: 4000
fn init(socket: posix.socket_t, server: *Server) !Client {
fn init(
socket: posix.socket_t,
allocator: Allocator,
app: *App,
json_version_response: []const u8,
timeout_ms: u32,
) !Client {
if (log.enabled(.app, .info)) {
var client_address: std.net.Address = undefined;
var socklen: posix.socklen_t = @sizeOf(net.Address);
try std.posix.getsockname(socket, &client_address.any, &socklen);
log.info(.app, "client connected", .{ .ip = client_address });
}
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
lp.assert(socket_flags & nonblocking == nonblocking, "Client.init blocking", .{});
var reader = try Reader(true).init(server.allocator);
var reader = try Reader(true).init(allocator);
errdefer reader.deinit();
const http = try app.http.createClient(allocator);
errdefer http.deinit();
return .{
.socket = socket,
.server = server,
.allocator = allocator,
.app = app,
.http = http,
.json_version_response = json_version_response,
.reader = reader,
.mode = .{ .http = {} },
.socket_flags = socket_flags,
.send_arena = ArenaAllocator.init(server.allocator),
.send_arena = ArenaAllocator.init(allocator),
.timeout_ms = timeout_ms,
};
}
fn stop(self: *Client) void {
posix.shutdown(self.socket, .recv) catch {};
}
fn deinit(self: *Client) void {
switch (self.mode) {
.cdp => |*cdp| cdp.deinit(),
@@ -259,6 +331,88 @@ pub const Client = struct {
}
self.reader.deinit();
self.send_arena.deinit();
self.http.deinit();
}
fn start(self: *Client) void {
const http = self.http;
http.cdp_client = .{
.socket = self.socket,
.ctx = self,
.blocking_read_start = Client.blockingReadStart,
.blocking_read = Client.blockingRead,
.blocking_read_end = Client.blockingReadStop,
};
defer http.cdp_client = null;
self.httpLoop(http) catch |err| {
log.err(.app, "CDP client loop", .{ .err = err });
};
}
fn httpLoop(self: *Client, http: *HttpClient) !void {
lp.assert(self.mode == .http, "Client.httpLoop invalid mode", .{});
while (true) {
const status = http.tick(self.timeout_ms) catch |err| {
log.err(.app, "http tick", .{ .err = err });
return;
};
if (status != .cdp_socket) {
log.info(.app, "CDP timeout", .{});
return;
}
if (self.readSocket() == false) {
return;
}
if (self.mode == .cdp) {
break;
}
}
return self.cdpLoop(http);
}
fn cdpLoop(self: *Client, http: *HttpClient) !void {
var cdp = &self.mode.cdp;
var last_message = timestamp(.monotonic);
var ms_remaining = self.timeout_ms;
while (true) {
switch (cdp.pageWait(ms_remaining)) {
.cdp_socket => {
if (self.readSocket() == false) {
return;
}
last_message = timestamp(.monotonic);
ms_remaining = self.timeout_ms;
},
.no_page => {
const status = http.tick(ms_remaining) catch |err| {
log.err(.app, "http tick", .{ .err = err });
return;
};
if (status != .cdp_socket) {
log.info(.app, "CDP timeout", .{});
return;
}
if (self.readSocket() == false) {
return;
}
last_message = timestamp(.monotonic);
ms_remaining = self.timeout_ms;
},
.done => {
const elapsed = timestamp(.monotonic) - last_message;
if (elapsed > ms_remaining) {
log.info(.app, "CDP timeout", .{});
return;
}
ms_remaining -= @intCast(elapsed);
},
}
}
}
fn blockingReadStart(ctx: *anyopaque) bool {
@@ -315,7 +469,7 @@ pub const Client = struct {
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) {
if (request.len > Config.CDP_MAX_HTTP_REQUEST_SIZE) {
self.writeHTTPErrorResponse(413, "Request too large");
return error.RequestTooLarge;
}
@@ -368,7 +522,7 @@ pub const Client = struct {
}
if (std.mem.eql(u8, url, "/json/version")) {
try self.send(self.server.json_version_response);
try self.send(self.json_version_response);
// Chromedp (a Go driver) does an http request to /json/version
// then to / (websocket upgrade) using a different connection.
// Since we only allow 1 connection at a time, the 2nd one (the
@@ -473,7 +627,7 @@ pub const Client = struct {
break :blk res;
};
self.mode = .{ .cdp = try CDP.init(self.server.app, self) };
self.mode = .{ .cdp = try CDP.init(self.app, self.http, self) };
return self.send(response);
}
@@ -561,7 +715,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`.
@@ -708,7 +862,7 @@ fn Reader(comptime EXPECT_MASK: bool) type {
if (message_len > 125) {
return error.ControlTooLarge;
}
} else if (message_len > MAX_MESSAGE_SIZE) {
} else if (message_len > Config.CDP_MAX_MESSAGE_SIZE) {
return error.TooLarge;
} else if (message_len > self.buf.len) {
const len = self.buf.len;
@@ -736,7 +890,7 @@ fn Reader(comptime EXPECT_MASK: bool) type {
if (is_continuation) {
const fragments = &(self.fragments orelse return error.InvalidContinuation);
if (fragments.message.items.len + message_len > MAX_MESSAGE_SIZE) {
if (fragments.message.items.len + message_len > Config.CDP_MAX_MESSAGE_SIZE) {
return error.TooLarge;
}
@@ -883,7 +1037,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 +1061,7 @@ const OpCode = enum(u8) {
pong = 128 | 10,
};
fn fillWebsocketHeader(buf: std.ArrayListUnmanaged(u8)) []const u8 {
fn fillWebsocketHeader(buf: std.ArrayList(u8)) []const u8 {
// can't use buf[0..10] here, because the header length
// is variable. If it's just 2 bytes, for example, we need the
// framed message to be:
@@ -1342,7 +1496,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

@@ -25,11 +25,13 @@ 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 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,54 +42,40 @@ 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 = .{},
http_client: *HttpClient,
};
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,
.http_client = app.http.client,
.call_arena = ArenaAllocator.init(allocator),
.page_arena = ArenaAllocator.init(allocator),
.session_arena = ArenaAllocator.init(allocator),
.transfer_arena = ArenaAllocator.init(allocator),
.arena_pool = &app.arena_pool,
.http_client = opts.http_client,
};
}
pub fn deinit(self: *Browser) void {
self.closeSession();
self.env.deinit();
self.call_arena.deinit();
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;
}
@@ -95,8 +83,7 @@ pub fn closeSession(self: *Browser) void {
if (self.session) |*session| {
session.deinit();
self.session = null;
_ = self.session_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
self.env.lowMemoryNotification();
self.env.memoryPressureNotification(.critical);
}
}
@@ -104,6 +91,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

@@ -28,28 +28,52 @@ const Page = @import("Page.zig");
const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const Element = @import("webapi/Element.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
const EventKey = struct {
event_target: usize,
type_string: String,
};
const EventKeyContext = struct {
pub fn hash(_: @This(), key: EventKey) u64 {
var hasher = std.hash.Wyhash.init(0);
hasher.update(std.mem.asBytes(&key.event_target));
hasher.update(key.type_string.str());
return hasher.final();
}
pub fn eql(_: @This(), a: EventKey, b: EventKey) bool {
return a.event_target == b.event_target and a.type_string.eql(b.type_string);
}
};
pub const EventManager = @This();
page: *Page,
arena: Allocator,
listener_pool: std.heap.MemoryPool(Listener),
list_pool: std.heap.MemoryPool(std.DoublyLinkedList),
lookup: std.AutoHashMapUnmanaged(usize, *std.DoublyLinkedList),
lookup: std.HashMapUnmanaged(
EventKey,
*std.DoublyLinkedList,
EventKeyContext,
std.hash_map.default_max_load_percentage,
),
dispatch_depth: usize,
deferred_removals: std.ArrayList(struct { list: *std.DoublyLinkedList, listener: *Listener }),
pub fn init(page: *Page) EventManager {
pub fn init(arena: Allocator, page: *Page) EventManager {
return .{
.page = page,
.lookup = .{},
.arena = page.arena,
.list_pool = std.heap.MemoryPool(std.DoublyLinkedList).init(page.arena),
.listener_pool = std.heap.MemoryPool(Listener).init(page.arena),
.arena = arena,
.list_pool = .init(arena),
.listener_pool = .init(arena),
.dispatch_depth = 0,
.deferred_removals = .{},
};
@@ -69,7 +93,7 @@ pub const Callback = union(enum) {
pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, opts: RegisterOptions) !void {
if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.register", .{ .type = typ, .capture = opts.capture, .once = opts.once, .target = target });
log.debug(.event, "eventManager.register", .{ .type = typ, .capture = opts.capture, .once = opts.once, .target = target.toString() });
}
// If a signal is provided and already aborted, don't register the listener
@@ -79,20 +103,24 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call
}
}
const gop = try self.lookup.getOrPut(self.arena, @intFromPtr(target));
// Allocate the type string we'll use in both listener and key
const type_string = try String.init(self.arena, typ, .{});
const gop = try self.lookup.getOrPut(self.arena, .{
.type_string = type_string,
.event_target = @intFromPtr(target),
});
if (gop.found_existing) {
// check for duplicate callbacks already registered
var node = gop.value_ptr.*.first;
while (node) |n| {
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
if (listener.typ.eqlSlice(typ)) {
const is_duplicate = switch (callback) {
.object => |obj| listener.function.eqlObject(obj),
.function => |func| listener.function.eqlFunction(func),
};
if (is_duplicate and listener.capture == opts.capture) {
return;
}
const is_duplicate = switch (callback) {
.object => |obj| listener.function.eqlObject(obj),
.function => |func| listener.function.eqlFunction(func),
};
if (is_duplicate and listener.capture == opts.capture) {
return;
}
node = n.next;
}
@@ -114,20 +142,34 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call
.passive = opts.passive,
.function = func,
.signal = opts.signal,
.typ = try String.init(self.arena, typ, .{}),
.typ = type_string,
};
// append the listener to the list of listeners for this target
gop.value_ptr.*.append(&listener.node);
}
pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void {
const list = self.lookup.get(@intFromPtr(target)) orelse return;
if (findListener(list, typ, callback, use_capture)) |listener| {
const list = self.lookup.get(.{
.type_string = .wrap(typ),
.event_target = @intFromPtr(target),
}) orelse return;
if (findListener(list, callback, use_capture)) |listener| {
self.removeListener(list, listener);
}
}
pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void {
// Dispatching can be recursive from the compiler's point of view, so we need to
// give it an explicit error set so that other parts of the code can use and
// inferred error.
const DispatchError = error{
OutOfMemory,
StringTooLarge,
JSExecCallback,
CompilationError,
ExecutionError,
JsException,
};
pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) DispatchError!void {
if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
}
@@ -137,7 +179,10 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void
var was_handled = false;
defer if (was_handled) {
self.page.js.runMicrotasks();
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
};
switch (target._type) {
@@ -151,9 +196,13 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void
.navigation,
.screen,
.screen_orientation,
.visual_viewport,
.generic,
=> {
const list = self.lookup.get(@intFromPtr(target)) orelse return;
const list = self.lookup.get(.{
.event_target = @intFromPtr(target),
.type_string = event._type_string,
}) orelse return;
try self.dispatchAll(list, target, event, &was_handled);
},
}
@@ -180,7 +229,10 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
var was_dispatched = false;
defer if (was_dispatched) {
self.page.js.runMicrotasks();
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
};
if (function_) |func| {
@@ -193,27 +245,37 @@ 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);
}
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool) !void {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
const page = self.page;
const activation_state = ActivationState.create(event, target, page);
// Defer runs even on early return - ensures event phase is reset
// and default actions execute (unless prevented)
defer {
event._event_phase = .none;
// Handle checkbox/radio activation rollback or commit
if (activation_state) |state| {
state.restore(event, page);
}
// Execute default action if not prevented
if (event._prevent_default) {
// can't return in a defer (╯°□°)╯︵ ┻━┻
} else if (event._type_string.eqlSlice("click")) {
self.page.handleClick(target) catch |err| {
} else if (event._type_string.eql(comptime .wrap("click"))) {
page.handleClick(target) catch |err| {
log.warn(.event, "page.click", .{ .err = err });
};
} else if (event._type_string.eqlSlice("keydown")) {
self.page.handleKeydown(target, event) catch |err| {
} else if (event._type_string.eql(comptime .wrap("keydown"))) {
page.handleKeydown(target, event) catch |err| {
log.warn(.event, "page.keydown", .{ .err = err });
};
}
@@ -248,7 +310,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
// Even though the window isn't part of the DOM, events always propagate
// through it in the capture phase (unless we stopped at a shadow boundary)
if (path_len < path_buffer.len) {
path_buffer[path_len] = self.page.window.asEventTarget();
path_buffer[path_len] = page.window.asEventTarget();
path_len += 1;
}
@@ -261,7 +323,10 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
while (i > 1) {
i -= 1;
const current_target = path[i];
if (self.lookup.get(@intFromPtr(current_target))) |list| {
if (self.lookup.get(.{
.event_target = @intFromPtr(current_target),
.type_string = event._type_string,
})) |list| {
try self.dispatchPhase(list, current_target, event, was_handled, true);
if (event._stop_propagation) {
return;
@@ -272,10 +337,36 @@ 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| {
try self.dispatchPhase(list, target_et, event, was_handled, null);
if (event._stop_propagation) {
return;
blk: {
// Get inline handler (e.g., onclick property) for this target
if (self.getInlineHandler(target_et, event)) |inline_handler| {
was_handled.* = true;
event._current_target = target_et;
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
try ls.toLocal(inline_handler).callWithThis(void, target_et, .{event});
if (event._stop_propagation) {
return;
}
if (event._stop_immediate_propagation) {
break :blk;
}
}
if (self.lookup.get(.{
.type_string = event._type_string,
.event_target = @intFromPtr(target_et),
})) |list| {
try self.dispatchPhase(list, target_et, event, was_handled, null);
if (event._stop_propagation) {
return;
}
}
}
@@ -284,7 +375,10 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
if (event._bubbles) {
event._event_phase = .bubbling_phase;
for (path[1..]) |current_target| {
if (self.lookup.get(@intFromPtr(current_target))) |list| {
if (self.lookup.get(.{
.type_string = event._type_string,
.event_target = @intFromPtr(current_target),
})) |list| {
try self.dispatchPhase(list, current_target, event, was_handled, false);
if (event._stop_propagation) {
break;
@@ -296,7 +390,6 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime capture_only: ?bool) !void {
const page = self.page;
const typ = event._type_string;
// Track dispatch depth for deferred removal
self.dispatch_depth += 1;
@@ -331,9 +424,6 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
node = n.next;
// Skip non-matching listeners
if (!listener.typ.eql(typ)) {
continue;
}
if (comptime capture_only) |capture| {
if (listener.capture != capture) {
continue;
@@ -367,14 +457,18 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
event._target = getAdjustedTarget(original_target, current_target);
}
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
switch (listener.function) {
.value => |value| try value.local().callWithThis(void, current_target, .{event}),
.value => |value| try ls.toLocal(value).callWithThis(void, current_target, .{event}),
.string => |string| {
const str = try page.call_arena.dupeZ(u8, string.str());
try self.page.js.eval(str, null);
try ls.local.eval(str, null);
},
.object => |*obj_global| {
const obj = obj_global.local();
.object => |obj_global| {
const obj = ls.toLocal(obj_global);
if (try obj.getFunction("handleEvent")) |handleEvent| {
try handleEvent.callWithThis(void, obj, .{event});
}
@@ -397,6 +491,19 @@ fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target:
return self.dispatchPhase(list, current_target, event, was_handled, null);
}
fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?js.Function.Global {
const global_event_handlers = @import("webapi/global_event_handlers.zig");
const handler_type = global_event_handlers.fromEventType(event._type_string.str()) orelse return null;
// Look up the inline handler for this target
const element = switch (target._type) {
.node => |n| n.is(Element) orelse return null,
else => return null,
};
return self.page.getAttrListener(element, handler_type);
}
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) {
@@ -409,7 +516,7 @@ fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *L
}
}
fn findListener(list: *const std.DoublyLinkedList, typ: []const u8, callback: Callback, capture: bool) ?*Listener {
fn findListener(list: *const std.DoublyLinkedList, callback: Callback, capture: bool) ?*Listener {
var node = list.first;
while (node) |n| {
node = n.next;
@@ -424,9 +531,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;
@@ -515,3 +619,144 @@ fn isAncestorOrSelf(ancestor: *Node, node: *Node) bool {
return false;
}
// Handles the default action for clicking on input checked/radio. Maybe this
// could be generalized if needed, but I'm not sure. This wasn't obvious to me
// but when an input is clicked, it's important to think about both the intent
// and the actual result. Imagine you have an unchecked checkbox. When clicked,
// the checkbox immediately becomes checked, and event handlers see this "checked"
// intent. But a listener can preventDefault() in which case the check we did at
// the start will be undone.
// This is a bit more complicated for radio buttons, as the checking/unchecking
// and the rollback can impact a different radio input. So if you "check" a radio
// the intent is that it becomes checked and whatever was checked before becomes
// unchecked, so that if you have to rollback (because of a preventDefault())
// then both inputs have to revert to their original values.
const ActivationState = struct {
old_checked: bool,
input: *Element.Html.Input,
previously_checked_radio: ?*Input,
const Input = Element.Html.Input;
fn create(event: *const Event, target: *Node, page: *Page) ?ActivationState {
if (event._type_string.eql(comptime .wrap("click")) == false) {
return null;
}
const input = target.is(Element.Html.Input) orelse return null;
if (input._input_type != .checkbox and input._input_type != .radio) {
return null;
}
const old_checked = input._checked;
var previously_checked_radio: ?*Element.Html.Input = null;
// For radio buttons, find the currently checked radio in the group
if (input._input_type == .radio and !old_checked) {
previously_checked_radio = try findCheckedRadioInGroup(input, page);
}
// Toggle checkbox or check radio (which unchecks others in group)
const new_checked = if (input._input_type == .checkbox) !old_checked else true;
try input.setChecked(new_checked, page);
return .{
.input = input,
.old_checked = old_checked,
.previously_checked_radio = previously_checked_radio,
};
}
fn restore(self: *const ActivationState, event: *const Event, page: *Page) void {
const input = self.input;
if (event._prevent_default) {
// Rollback: restore previous state
input._checked = self.old_checked;
input._checked_dirty = true;
if (self.previously_checked_radio) |prev_radio| {
prev_radio._checked = true;
prev_radio._checked_dirty = true;
}
return;
}
// Commit: fire input and change events only if state actually changed
// For checkboxes, state always changes. For radios, only if was unchecked.
const state_changed = (input._input_type == .checkbox) or !self.old_checked;
if (state_changed) {
fireEvent(page, input, "input") catch |err| {
log.warn(.event, "input event", .{ .err = err });
};
fireEvent(page, input, "change") catch |err| {
log.warn(.event, "change event", .{ .err = err });
};
}
}
fn findCheckedRadioInGroup(input: *Input, page: *Page) !?*Input {
const elem = input.asElement();
const name = elem.getAttributeSafe(comptime .wrap("name")) orelse return null;
if (name.len == 0) {
return null;
}
const form = input.getForm(page);
// Walk from the root of the tree containing this element
// This handles both document-attached and orphaned elements
const root = elem.asNode().getRootNode(null);
const TreeWalker = @import("webapi/TreeWalker.zig");
var walker = TreeWalker.Full.init(root, .{});
while (walker.next()) |node| {
const other_element = node.is(Element) orelse continue;
const other_input = other_element.is(Input) orelse continue;
if (other_input._input_type != .radio) {
continue;
}
// Skip the input we're checking from
if (other_input == input) {
continue;
}
const other_name = other_element.getAttributeSafe(comptime .wrap("name")) orelse continue;
if (!std.mem.eql(u8, name, other_name)) {
continue;
}
// Check if same form context
const other_form = other_input.getForm(page);
if (form) |f| {
const of = other_form orelse continue;
if (f != of) {
continue; // Different forms
}
} else if (other_form != null) {
continue; // form is null but other has a form
}
if (other_input._checked) {
return other_input;
}
}
return null;
}
// Fire input or change event
fn fireEvent(page: *Page, input: *Input, comptime typ: []const u8) !void {
const event = try Event.initTrusted(comptime .wrap(typ), .{
.bubbles = true,
.cancelable = false,
}, page);
defer if (!event._v8_handoff) event.deinit(false);
const target = input.asElement().asEventTarget();
try page._event_manager.dispatch(target, event);
}
};

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -29,6 +29,7 @@ const Page = @import("Page.zig");
const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig");
const UIEvent = @import("webapi/event/UIEvent.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig");
const Element = @import("webapi/Element.zig");
const Document = @import("webapi/Document.zig");
const EventTarget = @import("webapi/EventTarget.zig");
@@ -36,11 +37,15 @@ 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,
_arena: Allocator,
_slab: SlabAllocator,
fn PrototypeChain(comptime types: []const type) type {
@@ -146,10 +151,11 @@ fn AutoPrototypeChain(comptime types: []const type) type {
};
}
pub fn init(page: *Page) Factory {
pub fn init(arena: Allocator, page: *Page) Factory {
return .{
._page = page,
._slab = SlabAllocator.init(page.arena, 128),
._arena = arena,
._slab = SlabAllocator.init(arena, 128),
};
}
@@ -169,50 +175,76 @@ pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
return chain.get(1);
}
fn eventInit(typ: []const u8, value: anytype, page: *Page) !Event {
// Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2;
return .{
._type = unionInit(Event.Type, value),
._type_string = try String.init(page.arena, typ, .{}),
._time_stamp = time_stamp,
};
pub fn standaloneEventTarget(self: *Factory, child: anytype) !*EventTarget {
const allocator = self._slab.allocator();
const et = try allocator.create(EventTarget);
et.* = .{ ._type = unionInit(EventTarget.Type, child) };
return et;
}
// this is a root object
pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
pub fn event(self: *Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, @TypeOf(child) },
).allocate(allocator);
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(typ, chain.get(1), self._page);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn uiEvent(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
pub fn uiEvent(self: *Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, @TypeOf(child) },
).allocate(allocator);
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(typ, chain.get(1), self._page);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
chain.setLeaf(2, child);
return chain.get(2);
}
pub fn mouseEvent(self: *Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, MouseEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
// Set MouseEvent with all its fields
const mouse_ptr = chain.get(2);
mouse_ptr.* = mouse;
mouse_ptr._proto = chain.get(1);
mouse_ptr._type = unionInit(MouseEvent.Type, chain.get(3));
chain.setLeaf(3, child);
return chain.get(3);
}
fn eventInit(self: *const Factory, arena: Allocator, typ: String, value: anytype) !Event {
// Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2;
return .{
._arena = arena,
._page = self._page,
._type = unionInit(Event.Type, value),
._type_string = typ,
._time_stamp = time_stamp,
};
}
pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
@@ -307,7 +339,7 @@ pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeO
chain.setMiddle(2, Element.Type);
// will never allocate, can't fail
const tag_name_str = String.init(self._page.arena, tag_name, .{}) catch unreachable;
const tag_name_str = String.init(self._arena, tag_name, .{}) catch unreachable;
// Manually set Element.Svg with the tag_name
chain.set(3, .{
@@ -320,9 +352,7 @@ pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeO
return chain.get(4);
}
pub fn xhrEventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
pub fn xhrEventTarget(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) {
return try AutoPrototypeChain(
&.{ EventTarget, XMLHttpRequestEventTarget, @TypeOf(child) },
).create(allocator, child);
@@ -337,32 +367,6 @@ pub fn textTrackCue(self: *Factory, child: anytype) !*@TypeOf(child) {
).create(allocator, child);
}
fn hasChainRoot(comptime T: type) bool {
// Check if this is a root
if (@hasDecl(T, "_prototype_root")) {
return true;
}
// If no _proto field, we're at the top but not a recognized root
if (!@hasField(T, "_proto")) return false;
// Get the _proto field's type and recurse
const fields = @typeInfo(T).@"struct".fields;
inline for (fields) |field| {
if (std.mem.eql(u8, field.name, "_proto")) {
const ProtoType = reflect.Struct(field.type);
return hasChainRoot(ProtoType);
}
}
return false;
}
fn isChainType(comptime T: type) bool {
if (@hasField(T, "_proto")) return false;
return comptime hasChainRoot(T);
}
pub fn destroy(self: *Factory, value: anytype) void {
const S = reflect.Struct(@TypeOf(value));
@@ -379,7 +383,7 @@ pub fn destroy(self: *Factory, value: anytype) void {
}
}
if (comptime isChainType(S)) {
if (comptime @hasField(S, "_proto")) {
self.destroyChain(value, true, 0, std.mem.Alignment.@"1");
} else {
self.destroyStandalone(value);
@@ -387,20 +391,7 @@ pub fn destroy(self: *Factory, value: anytype) void {
}
pub fn destroyStandalone(self: *Factory, value: anytype) void {
const S = reflect.Struct(@TypeOf(value));
assert(!@hasDecl(S, "_prototype_root"));
const allocator = self._slab.allocator();
if (@hasDecl(S, "deinit")) {
// And it has a deinit, we'll call it
switch (@typeInfo(@TypeOf(S.deinit)).@"fn".params.len) {
1 => value.deinit(),
2 => value.deinit(self._page),
else => @compileLog(@typeName(S) ++ " has an invalid deinit function"),
}
}
allocator.destroy(value);
}
@@ -416,10 +407,8 @@ fn destroyChain(
// aligns the old size to the alignment of this element
const current_size = std.mem.alignForward(usize, old_size, @alignOf(S));
const alignment = std.mem.Alignment.fromByteUnits(@alignOf(S));
const new_align = std.mem.Alignment.max(old_align, alignment);
const new_size = current_size + @sizeOf(S);
const new_align = std.mem.Alignment.max(old_align, std.mem.Alignment.of(S));
// This is initially called from a deinit. We don't want to call that
// same deinit. So when this is the first time destroyChain is called
@@ -438,20 +427,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());
}
}

View File

@@ -38,6 +38,10 @@ pub const ContentTypeEnum = enum {
text_javascript,
text_plain,
text_css,
image_jpeg,
image_gif,
image_png,
image_webp,
application_json,
unknown,
other,
@@ -49,6 +53,10 @@ pub const ContentType = union(ContentTypeEnum) {
text_javascript: void,
text_plain: void,
text_css: void,
image_jpeg: void,
image_gif: void,
image_png: void,
image_webp: void,
application_json: void,
unknown: void,
other: struct { type: []const u8, sub_type: []const u8 },
@@ -61,6 +69,10 @@ pub fn contentTypeString(mime: *const Mime) []const u8 {
.text_javascript => "application/javascript",
.text_plain => "text/plain",
.text_css => "text/css",
.image_jpeg => "image/jpeg",
.image_png => "image/png",
.image_gif => "image/gif",
.image_webp => "image/webp",
.application_json => "application/json",
else => "",
};
@@ -243,6 +255,11 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
@"application/javascript",
@"application/x-javascript",
@"image/jpeg",
@"image/png",
@"image/gif",
@"image/webp",
@"application/json",
}, type_name)) |known_type| {
const ct: ContentType = switch (known_type) {
@@ -251,6 +268,10 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
.@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} },
.@"text/plain" => .{ .text_plain = {} },
.@"text/css" => .{ .text_css = {} },
.@"image/jpeg" => .{ .image_jpeg = {} },
.@"image/png" => .{ .image_png = {} },
.@"image/gif" => .{ .image_gif = {} },
.@"image/webp" => .{ .image_webp = {} },
.@"application/json" => .{ .application_json = {} },
};
return .{ ct, attribute_start };
@@ -358,6 +379,11 @@ test "Mime: parse common" {
try expect(.{ .content_type = .{ .application_json = {} } }, "application/json");
try expect(.{ .content_type = .{ .text_css = {} } }, "text/css");
try expect(.{ .content_type = .{ .image_jpeg = {} } }, "image/jpeg");
try expect(.{ .content_type = .{ .image_png = {} } }, "image/png");
try expect(.{ .content_type = .{ .image_gif = {} } }, "image/gif");
try expect(.{ .content_type = .{ .image_webp = {} } }, "image/webp");
}
test "Mime: parse uncommon" {

File diff suppressed because it is too large Load Diff

1003
src/browser/Robots.zig Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,7 @@ const Http = @import("../http/Http.zig");
const Element = @import("webapi/Element.zig");
const Allocator = std.mem.Allocator;
const ArrayListUnmanaged = std.ArrayListUnmanaged;
const ArrayList = std.ArrayList;
const IS_DEBUG = builtin.mode == .Debug;
@@ -83,10 +83,7 @@ imported_modules: std.StringHashMapUnmanaged(ImportedModule),
// importmap contains resolved urls.
importmap: std.StringHashMapUnmanaged([:0]const u8),
pub fn init(page: *Page) ScriptManager {
// page isn't fully initialized, we can setup our reference, but that's it.
const browser = page._session.browser;
const allocator = browser.allocator;
pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) ScriptManager {
return .{
.page = page,
.async_scripts = .{},
@@ -96,7 +93,7 @@ pub fn init(page: *Page) ScriptManager {
.is_evaluating = false,
.allocator = allocator,
.imported_modules = .empty,
.client = browser.http_client,
.client = http_client,
.static_scripts_done = false,
.buffer_pool = BufferPool.init(allocator, 5),
.script_pool = std.heap.MemoryPool(Script).init(allocator),
@@ -138,6 +135,12 @@ fn clearList(list: *std.DoublyLinkedList) void {
}
}
pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !Http.Headers {
var headers = try self.client.newHeaders();
try self.page.headersForRequest(self.page.arena, url, &headers);
return headers;
}
pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_element: *Element.Html.Script, comptime ctx: []const u8) !void {
if (script_element._executed) {
// If a script tag gets dynamically created and added to the dom:
@@ -152,14 +155,14 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
script_element._executed = true;
const element = script_element.asElement();
if (element.getAttributeSafe("nomodule") != null) {
if (element.getAttributeSafe(comptime .wrap("nomodule")) != null) {
// these scripts should only be loaded if we don't support modules
// but since we do support modules, we can just skip them.
return;
}
const kind: Script.Kind = blk: {
const script_type = element.getAttributeSafe("type") orelse break :blk .javascript;
const script_type = element.getAttributeSafe(comptime .wrap("type")) orelse break :blk .javascript;
if (script_type.len == 0) {
break :blk .javascript;
}
@@ -186,7 +189,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
var source: Script.Source = undefined;
var remote_url: ?[:0]const u8 = null;
const base_url = page.base();
if (element.getAttributeSafe("src")) |src| {
if (element.getAttributeSafe(comptime .wrap("src"))) |src| {
if (try parseDataURI(page.arena, src)) |data_uri| {
source = .{ .@"inline" = data_uri };
} else {
@@ -217,12 +220,12 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
break :blk if (kind == .module) .@"defer" else .normal;
}
if (element.getAttributeSafe("async") != null) {
if (element.getAttributeSafe(comptime .wrap("async")) != null) {
break :blk .async;
}
// Check for defer or module (before checking dynamic script default)
if (kind == .module or element.getAttributeSafe("defer") != null) {
if (kind == .module or element.getAttributeSafe(comptime .wrap("defer")) != null) {
break :blk .@"defer";
}
@@ -252,17 +255,15 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
script.deinit(true);
}
var headers = try self.client.newHeaders();
try page.requestCookie(.{}).headersForRequest(page.arena, url, &headers);
try self.client.request(.{
.url = url,
.ctx = script,
.method = .GET,
.headers = headers,
.headers = try self.getHeaders(url),
.blocking = is_blocking,
.cookie_jar = &page._session.cookie_jar,
.resource_type = .script,
.notification = page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
@@ -271,11 +272,15 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
});
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.ctx = ctx,
.url = remote_url.?,
.element = element,
.stack = page.js.stackTrace() catch "???",
.stack = ls.local.stackTrace() catch "???",
});
}
}
@@ -353,15 +358,16 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.manager = self,
};
var headers = try self.client.newHeaders();
try self.page.requestCookie(.{}).headersForRequest(self.page.arena, url, &headers);
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.url = url,
.ctx = "module",
.referrer = referrer,
.stack = self.page.js.stackTrace() catch "???",
.stack = ls.local.stackTrace() catch "???",
});
}
@@ -369,9 +375,10 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.url = url,
.ctx = script,
.method = .GET,
.headers = headers,
.headers = try self.getHeaders(url),
.cookie_jar = &self.page._session.cookie_jar,
.resource_type = .script,
.notification = self.page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
@@ -444,15 +451,16 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
} },
};
var headers = try self.client.newHeaders();
try self.page.requestCookie(.{}).headersForRequest(self.page.arena, url, &headers);
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.url = url,
.ctx = "dynamic module",
.referrer = referrer,
.stack = self.page.js.stackTrace() catch "???",
.stack = ls.local.stackTrace() catch "???",
});
}
@@ -468,10 +476,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,
@@ -608,6 +617,7 @@ pub const Script = struct {
node: std.DoublyLinkedList.Node,
script_element: ?*Element.Html.Script,
manager: *ScriptManager,
header_callback_called: bool = false,
const Kind = enum {
module,
@@ -622,7 +632,7 @@ pub const Script = struct {
const Source = union(enum) {
@"inline": []const u8,
remote: std.ArrayListUnmanaged(u8),
remote: std.ArrayList(u8),
fn content(self: Source) []const u8 {
return switch (self) {
@@ -651,7 +661,7 @@ pub const Script = struct {
log.debug(.http, "script fetch start", .{ .req = transfer });
}
fn headerCallback(transfer: *Http.Transfer) !void {
fn headerCallback(transfer: *Http.Transfer) !bool {
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
const header = &transfer.response_header.?;
self.status = header.status;
@@ -661,7 +671,7 @@ pub const Script = struct {
.status = header.status,
.content_type = header.contentType(),
});
return;
return false;
}
if (comptime IS_DEBUG) {
@@ -672,16 +682,21 @@ pub const Script = struct {
});
}
// If this isn't true, then we'll likely leak memory. If you don't
// set `CURLOPT_SUPPRESS_CONNECT_HEADERS` and CONNECT to a proxy, this
// will fail. This assertion exists to catch incorrect assumptions about
// how libcurl works, or about how we've configured it.
lp.assert(self.source.remote.capacity == 0, "ScriptManager.HeaderCallback", .{ .capacity = self.source.remote.capacity });
{
// temp debug, trying to figure out why the next assert sometimes
// fails. Is the buffer just corrupt or is headerCallback really
// being called twice?
lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{});
self.header_callback_called = true;
}
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });
var buffer = self.manager.buffer_pool.get();
if (transfer.getContentLength()) |cl| {
try buffer.ensureTotalCapacity(self.manager.allocator, cl);
}
self.source = .{ .remote = buffer };
return true;
}
fn dataCallback(transfer: *Http.Transfer, data: []const u8) !void {
@@ -721,7 +736,7 @@ pub const Script = struct {
log.warn(.http, "script fetch error", .{
.err = err,
.req = self.url,
.mode = self.mode,
.mode = std.meta.activeTag(self.mode),
.kind = self.kind,
.status = self.status,
});
@@ -741,9 +756,13 @@ pub const Script = struct {
return;
}
if (self.mode == .import) {
const entry = self.manager.imported_modules.getPtr(self.url).?;
entry.state = .err;
switch (self.mode) {
.import_async => |ia| ia.callback(ia.data, error.FailedToLoad),
.import => {
const entry = manager.imported_modules.getPtr(self.url).?;
entry.state = .err;
},
else => {},
}
self.deinit(true);
manager.evaluate();
@@ -785,6 +804,12 @@ pub const Script = struct {
.cacheable = cacheable,
});
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
const local = &ls.local;
// Handle importmap special case here: the content is a JSON containing
// imports.
if (self.kind == .importmap) {
@@ -795,25 +820,24 @@ pub const Script = struct {
.kind = self.kind,
.cacheable = cacheable,
});
self.executeCallback("error", script_element._on_error, page);
self.executeCallback("error", local.toLocal(script_element._on_error), page);
return;
};
self.executeCallback("load", script_element._on_load, page);
self.executeCallback("load", local.toLocal(script_element._on_load), page);
return;
}
const js_context = page.js;
var try_catch: js.TryCatch = undefined;
try_catch.init(js_context);
try_catch.init(local);
defer try_catch.deinit();
const success = blk: {
const content = self.source.content();
switch (self.kind) {
.javascript => _ = js_context.eval(content, url) catch break :blk false,
.javascript => _ = local.eval(content, url) catch break :blk false,
.module => {
// We don't care about waiting for the evaluation here.
js_context.module(false, content, url, cacheable) catch break :blk false;
page.js.module(false, local, content, url, cacheable) catch break :blk false;
},
.importmap => unreachable, // handled before the try/catch.
}
@@ -826,14 +850,14 @@ pub const Script = struct {
defer {
// We should run microtasks even if script execution fails.
page.js.runMicrotasks();
_ = page.scheduler.run() catch |err| {
local.runMicrotasks();
_ = page.js.scheduler.run() catch |err| {
log.err(.page, "scheduler", .{ .err = err });
};
}
if (success) {
self.executeCallback("load", script_element._on_load, page);
self.executeCallback("load", local.toLocal(script_element._on_load), page);
return;
}
@@ -844,15 +868,14 @@ pub const Script = struct {
.cacheable = cacheable,
});
self.executeCallback("error", script_element._on_error, page);
self.executeCallback("error", local.toLocal(script_element._on_error), page);
}
fn executeCallback(self: *const Script, comptime typ: []const u8, cb_: ?js.Function.Global, page: *Page) void {
const cb_global = cb_ orelse return;
const cb = cb_global.local();
fn executeCallback(self: *const Script, comptime typ: []const u8, cb_: ?js.Function, page: *Page) void {
const cb = cb_ orelse return;
const Event = @import("webapi/Event.zig");
const event = Event.initTrusted(typ, .{}, page) catch |err| {
const event = Event.initTrusted(comptime .wrap(typ), .{}, page) catch |err| {
log.warn(.js, "script internal callback", .{
.url = self.url,
.type = typ,
@@ -860,6 +883,7 @@ pub const Script = struct {
});
return;
};
defer if (!event._v8_handoff) event.deinit(false);
var caught: js.TryCatch.Caught = undefined;
cb.tryCall(void, .{event}, &caught) catch {
@@ -879,11 +903,11 @@ const BufferPool = struct {
max_concurrent_transfers: u8,
mem_pool: std.heap.MemoryPool(Container),
const List = std.DoublyLinkedList;
const List = std.SinglyLinkedList;
const Container = struct {
node: List.Node,
buf: std.ArrayListUnmanaged(u8),
buf: std.ArrayList(u8),
};
fn init(allocator: Allocator, max_concurrent_transfers: u8) BufferPool {
@@ -908,7 +932,7 @@ const BufferPool = struct {
self.mem_pool.deinit();
}
fn get(self: *BufferPool) std.ArrayListUnmanaged(u8) {
fn get(self: *BufferPool) std.ArrayList(u8) {
const node = self.available.popFirst() orelse {
// return a new buffer
return .{};
@@ -920,7 +944,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;
@@ -938,7 +962,7 @@ const BufferPool = struct {
b.clearRetainingCapacity();
container.* = .{ .buf = b, .node = .{} };
self.count += 1;
self.available.append(&container.node);
self.available.prepend(&container.node);
}
};

View File

@@ -28,6 +28,7 @@ const History = @import("webapi/History.zig");
const Page = @import("Page.zig");
const Browser = @import("Browser.zig");
const Notification = @import("../Notification.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -39,6 +40,7 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
const Session = @This();
browser: *Browser,
notification: *Notification,
// Used to create our Inspector and in the BrowserContext.
arena: Allocator,
@@ -53,31 +55,33 @@ 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();
const arena = try browser.arena_pool.acquire();
errdefer browser.arena_pool.release(arena);
const transfer_arena = try browser.arena_pool.acquire();
errdefer browser.arena_pool.release(transfer_arena);
self.* = .{
.browser = browser,
.executor = executor,
.storage_shed = .{},
.arena = session_allocator,
.cookie_jar = storage.Cookie.Jar.init(allocator),
.navigation = .{},
.page = null,
.arena = arena,
.history = .{},
.transfer_arena = browser.transfer_arena.allocator(),
// The prototype (EventTarget) for Navigation is created when a Page is created.
.navigation = .{ ._proto = undefined },
.storage_shed = .{},
.browser = browser,
.notification = notification,
.transfer_arena = transfer_arena,
.cookie_jar = storage.Cookie.Jar.init(allocator),
};
}
@@ -85,9 +89,12 @@ pub fn deinit(self: *Session) void {
if (self.page != null) {
self.removePage();
}
const browser = self.browser;
self.cookie_jar.deinit();
self.storage_shed.deinit(self.browser.app.allocator);
self.executor.deinit();
self.storage_shed.deinit(browser.app.allocator);
browser.arena_pool.release(self.transfer_arena);
browser.arena_pool.release(self.arena);
}
// NOTE: the caller is not the owner of the returned value,
@@ -95,11 +102,9 @@ pub fn deinit(self: *Session) void {
pub fn createPage(self: *Session) !*Page {
lp.assert(self.page == null, "Session.createPage - page not null", .{});
const page_arena = &self.browser.page_arena;
_ = page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
self.page = try Page.init(page_arena.allocator(), self.browser.call_arena.allocator(), self);
const page = self.page.?;
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, self);
// Creates a new NavigationEventTarget for this page.
try self.navigation.onNewPage(page);
@@ -109,14 +114,14 @@ pub fn createPage(self: *Session) !*Page {
}
// start JS env
// Inform CDP the main page has been created such that additional context for other Worlds can be created as well
self.browser.notification.dispatch(.page_created, page);
self.notification.dispatch(.page_created, page);
return page;
}
pub fn removePage(self: *Session) void {
// Inform CDP the page is going to be removed, allowing other worlds to remove themselves before the main one
self.browser.notification.dispatch(.page_remove, .{});
self.notification.dispatch(.page_remove, .{});
lp.assert(self.page != null, "Session.removePage - page is null", .{});
self.page.?.deinit();
@@ -129,23 +134,45 @@ pub fn removePage(self: *Session) void {
}
}
pub fn replacePage(self: *Session) !*Page {
if (comptime IS_DEBUG) {
log.debug(.browser, "replace page", .{});
}
lp.assert(self.page != null, "Session.replacePage null page", .{});
self.page.?.deinit();
self.browser.env.memoryPressureNotification(.moderate);
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, self);
return page;
}
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 +180,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 });
defer self.browser.arena_pool.reset(self.transfer_arena, 4 * 1024);
const url, const opts = blk: {
const qn = self.page.?._queued_navigation.?;
// qn might not be safe to use after self.removePage is called, hence
// this block;
const url = qn.url;
const opts = qn.opts;
// This was already aborted on the page, but it would be pretty
// bad if old requests went to the new page, so let's make double sure
self.browser.http_client.abort();
self.removePage();
// This was already aborted on the page, but it would be pretty
// bad if old requests went to the new page, so let's make double sure
self.browser.http_client.abort();
self.removePage();
break :blk .{ url, opts };
};
const page = self.createPage() catch |err| {
log.err(.browser, "queued navigation page error", .{
.err = err,
.url = qn.url,
.url = url,
});
return err;
};
page.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued navigation error", .{ .err = err, .url = qn.url });
page.navigate(url, opts) catch |err| {
log.err(.browser, "queued navigation error", .{ .err = err, .url = url });
return err;
};
}

View File

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

View File

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

View File

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

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

@@ -0,0 +1,732 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const string = @import("../../string.zig");
const Page = @import("../Page.zig");
const js = @import("js.zig");
const Local = @import("Local.zig");
const Context = @import("Context.zig");
const TaggedOpaque = @import("TaggedOpaque.zig");
const v8 = js.v8;
const ArenaAllocator = std.heap.ArenaAllocator;
const CALL_ARENA_RETAIN = 1024 * 16;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Caller = @This();
local: Local,
prev_local: ?*const js.Local,
prev_context: *Context,
// Takes the raw v8 isolate and extracts the context from it.
pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void {
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?;
initWithContext(self, Context.fromC(v8_context), v8_context);
}
fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void {
ctx.call_depth += 1;
self.* = Caller{
.local = .{
.ctx = ctx,
.handle = v8_context,
.call_arena = ctx.call_arena,
.isolate = ctx.isolate,
},
.prev_local = ctx.local,
.prev_context = ctx.page.js,
};
ctx.page.js = ctx;
ctx.local = &self.local;
}
pub fn deinit(self: *Caller) void {
const ctx = self.local.ctx;
const call_depth = ctx.call_depth - 1;
// Because of callbacks, calls can be nested. Because of this, we
// can't clear the call_arena after _every_ call. Imagine we have
// arr.forEach((i) => { console.log(i); }
//
// First we call forEach. Inside of our forEach call,
// we call console.log. If we reset the call_arena after this call,
// it'll reset it for the `forEach` call after, which might still
// need the data.
//
// Therefore, we keep a call_depth, and only reset the call_arena
// when a top-level (call_depth == 0) function ends.
if (call_depth == 0) {
const arena: *ArenaAllocator = @ptrCast(@alignCast(ctx.call_arena.ptr));
_ = arena.reset(.{ .retain_with_limit = CALL_ARENA_RETAIN });
}
ctx.call_depth = call_depth;
ctx.local = self.prev_local;
ctx.page.js = self.prev_context;
}
pub const CallOpts = struct {
dom_exception: bool = false,
null_as_undefined: bool = false,
as_typed_array: bool = false,
};
pub fn constructor(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = FunctionCallbackInfo{ .handle = handle };
if (!info.isConstructCall()) {
handleError(T, @TypeOf(func), local, error.InvalidArgument, info, opts);
return;
}
self._constructor(func, info) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
};
}
fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void {
const F = @TypeOf(func);
const local = &self.local;
const args = try getArgs(F, 0, local, info);
const res = @call(.auto, func, args);
const ReturnType = @typeInfo(F).@"fn".return_type orelse {
@compileError(@typeName(F) ++ " has a constructor without a return type");
};
const new_this_handle = info.getThis();
var this = js.Object{ .local = local, .handle = new_this_handle };
if (@typeInfo(ReturnType) == .error_union) {
const non_error_res = res catch |err| return err;
this = try local.mapZigInstanceToJs(new_this_handle, non_error_res);
} else {
this = try local.mapZigInstanceToJs(new_this_handle, res);
}
// If we got back a different object (existing wrapper), copy the prototype
// from new object. (this happens when we're upgrading an CustomElement)
if (this.handle != new_this_handle) {
const prototype_handle = v8.v8__Object__GetPrototype(new_this_handle).?;
var out: v8.MaybeBool = undefined;
v8.v8__Object__SetPrototype(this.handle, self.local.handle, prototype_handle, &out);
if (comptime IS_DEBUG) {
std.debug.assert(out.has_value and out.value);
}
}
info.getReturnValue().set(this.handle);
}
pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _getIndex(T, local, func, idx, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
};
}
fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = idx;
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = local.ctx.page;
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _getNamedIndex(T, local, func, name, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
};
}
fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name);
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = local.ctx.page;
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _setNamedIndex(T, local, func, name, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
};
}
fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name);
@field(args, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value);
if (@typeInfo(F).@"fn".params.len == 4) {
@field(args, "3") = local.ctx.page;
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, false, local, ret, info, opts);
}
pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _deleteNamedIndex(T, local, func, name, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
return 0;
};
}
fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name);
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = local.ctx.page;
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, false, local, ret, info, opts);
}
fn handleIndexedReturn(comptime T: type, comptime F: type, comptime getter: bool, local: *const Local, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
// need to unwrap this error immediately for when opts.null_as_undefined == true
// and we need to compare it to null;
const non_error_ret = switch (@typeInfo(@TypeOf(ret))) {
.error_union => |eu| blk: {
break :blk ret catch |err| {
// We can't compare err == error.NotHandled if error.NotHandled
// isn't part of the possible error set. So we first need to check
// if error.NotHandled is part of the error set.
if (isInErrorSet(error.NotHandled, eu.error_set)) {
if (err == error.NotHandled) {
// not intercepted
return 0;
}
}
handleError(T, F, local, err, info, opts);
// not intercepted
return 0;
};
},
else => ret,
};
if (comptime getter) {
info.getReturnValue().set(try local.zigValueToJs(non_error_ret, opts));
}
// intercepted
return 1;
}
fn isInErrorSet(err: anyerror, comptime T: type) bool {
inline for (@typeInfo(T).error_set.?) |e| {
if (err == @field(anyerror, e.name)) return true;
}
return false;
}
fn nameToString(local: *const Local, 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 = local, .handle = handle }, false);
}
if (T == string.Global) {
return js.String.toSSO(.{ .local = local, .handle = handle }, true);
}
return try js.String.toSlice(.{ .local = local, .handle = handle });
}
fn handleError(comptime T: type, comptime F: type, local: *const Local, err: anyerror, info: anytype, comptime opts: CallOpts) void {
const isolate = local.isolate;
if (comptime @import("builtin").mode == .Debug and @TypeOf(info) == FunctionCallbackInfo) {
if (log.enabled(.js, .warn)) {
logFunctionCallError(local, @typeName(T), @typeName(F), err, info);
}
}
const js_err: *const v8.Value = switch (err) {
error.TryCatchRethrow => return,
error.InvalidArgument => isolate.createTypeError("invalid argument"),
error.OutOfMemory => isolate.createError("out of memory"),
error.IllegalConstructor => isolate.createError("Illegal Contructor"),
else => blk: {
if (comptime opts.dom_exception) {
const DOMException = @import("../webapi/DOMException.zig");
if (DOMException.fromError(err)) |ex| {
const value = local.zigValueToJs(ex, .{}) catch break :blk isolate.createError("internal error");
break :blk value.handle;
}
}
break :blk isolate.createError(@errorName(err));
},
};
const js_exception = isolate.throwException(js_err);
info.getReturnValue().setValueHandle(js_exception);
}
// 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(local: *const Local, type_name: []const u8, func: []const u8, err: anyerror, info: FunctionCallbackInfo) void {
const args_dump = serializeFunctionArgs(local, info) catch "failed to serialize args";
log.info(.js, "function call error", .{
.type = type_name,
.func = func,
.err = err,
.args = args_dump,
.stack = local.stackTrace() catch |err1| @errorName(err1),
});
}
fn serializeFunctionArgs(local: *const Local, info: FunctionCallbackInfo) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(local.call_arena);
const separator = log.separator();
for (0..info.length()) |i| {
try buf.writer.print("{s}{d} - ", .{ separator, i + 1 });
const js_value = info.getArg(@intCast(i), local);
try local.debugValue(js_value, &buf.writer);
}
return buf.written();
}
// Takes a function, and returns a tuple for its argument. Used when we
// @call a function
fn ParameterTypes(comptime F: type) type {
const params = @typeInfo(F).@"fn".params;
var fields: [params.len]std.builtin.Type.StructField = undefined;
inline for (params, 0..) |param, i| {
fields[i] = .{
.name = tupleFieldName(i),
.type = param.type.?,
.default_value_ptr = null,
.is_comptime = false,
.alignment = @alignOf(param.type.?),
};
}
return @Type(.{ .@"struct" = .{
.layout = .auto,
.decls = &.{},
.fields = &fields,
.is_tuple = true,
} });
}
fn tupleFieldName(comptime i: usize) [:0]const u8 {
return switch (i) {
0 => "0",
1 => "1",
2 => "2",
3 => "3",
4 => "4",
5 => "5",
6 => "6",
7 => "7",
8 => "8",
9 => "9",
else => std.fmt.comptimePrint("{d}", .{i}),
};
}
fn isPage(comptime T: type) bool {
return T == *Page or T == *const Page;
}
// These wrap the raw v8 C API to provide a cleaner interface.
pub const FunctionCallbackInfo = struct {
handle: *const v8.FunctionCallbackInfo,
pub fn length(self: FunctionCallbackInfo) u32 {
return @intCast(v8.v8__FunctionCallbackInfo__Length(self.handle));
}
pub fn getArg(self: FunctionCallbackInfo, index: u32, local: *const js.Local) js.Value {
return .{ .local = local, .handle = v8.v8__FunctionCallbackInfo__INDEX(self.handle, @intCast(index)).? };
}
pub fn getThis(self: FunctionCallbackInfo) *const v8.Object {
return v8.v8__FunctionCallbackInfo__This(self.handle).?;
}
pub fn getReturnValue(self: FunctionCallbackInfo) ReturnValue {
var rv: v8.ReturnValue = undefined;
v8.v8__FunctionCallbackInfo__GetReturnValue(self.handle, &rv);
return .{ .handle = rv };
}
fn isConstructCall(self: FunctionCallbackInfo) bool {
return v8.v8__FunctionCallbackInfo__IsConstructCall(self.handle);
}
};
pub const PropertyCallbackInfo = struct {
handle: *const v8.PropertyCallbackInfo,
pub fn getThis(self: PropertyCallbackInfo) *const v8.Object {
return v8.v8__PropertyCallbackInfo__This(self.handle).?;
}
pub fn getReturnValue(self: PropertyCallbackInfo) ReturnValue {
var rv: v8.ReturnValue = undefined;
v8.v8__PropertyCallbackInfo__GetReturnValue(self.handle, &rv);
return .{ .handle = rv };
}
};
const ReturnValue = struct {
handle: v8.ReturnValue,
pub fn set(self: ReturnValue, value: anytype) void {
const T = @TypeOf(value);
if (T == *const v8.Object) {
self.setValueHandle(@ptrCast(value));
} else if (T == *const v8.Value) {
self.setValueHandle(value);
} else if (T == js.Value) {
self.setValueHandle(value.handle);
} else {
@compileError("Unsupported type for ReturnValue.set: " ++ @typeName(T));
}
}
pub fn setValueHandle(self: ReturnValue, handle: *const v8.Value) void {
v8.v8__ReturnValue__Set(self.handle, handle);
}
};
pub const Function = struct {
pub const Opts = struct {
static: bool = false,
dom_exception: bool = false,
as_typed_array: bool = false,
null_as_undefined: bool = false,
cache: ?Caching = null,
// We support two ways to cache a value directly into a v8::Object. The
// difference between the two is like the difference between a Map
// and a Struct.
// 1 - Using the object's internal fields. Think of this as
// adding a field to the struct. It's fast, but the space is reserved
// upfront for _every_ instance, whether we use it or not.
//
// 2 - Using the object's private state with a v8::Private key. Think of
// this as a HashMap. It takes no memory if the cache isn't used
// but has overhead when used.
//
// Consider `window.document`, (1) we have relatively few Window objects,
// (2) They all have a document and (3) The document is accessed _a lot_.
// An internal field makes sense.
//
// Consider `node.childNodes`, (1) we can have 20K+ node objects, (2)
// 95% of nodes will never have their .childNodes access by JavaScript.
// Private map lookup makes sense.
pub const Caching = union(enum) {
internal: u8,
private: []const u8,
};
};
pub fn call(comptime T: type, info_handle: *const v8.FunctionCallbackInfo, func: anytype, comptime opts: Opts) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(info_handle).?;
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?;
const ctx = Context.fromC(v8_context);
const info = FunctionCallbackInfo{ .handle = info_handle };
var hs: js.HandleScope = undefined;
hs.initWithIsolateHandle(v8_isolate);
defer hs.deinit();
var cache_state: CacheState = undefined;
if (comptime opts.cache) |cache| {
// This API is a bit weird. On
if (respondFromCache(cache, ctx, v8_context, info, &cache_state)) {
// Value was fetched from the cache and returned already
return;
} else {
// Cache miss: cache_state will have been populated
}
}
var caller: Caller = undefined;
caller.initWithContext(ctx, v8_context);
defer caller.deinit();
const js_value = _call(T, &caller.local, info, func, opts) catch |err| {
handleError(T, @TypeOf(func), &caller.local, err, info, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
return;
};
if (comptime opts.cache) |cache| {
cache_state.save(cache, js_value);
}
}
fn _call(comptime T: type, local: *const Local, info: FunctionCallbackInfo, func: anytype, comptime opts: Opts) !js.Value {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
if (comptime opts.static) {
args = try getArgs(F, 0, local, info);
} else {
args = try getArgs(F, 1, local, info);
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
}
const res = @call(.auto, func, args);
const js_value = try local.zigValueToJs(res, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
info.getReturnValue().set(js_value);
return js_value;
}
// We can cache a value directly into the v8::Object so that our callback to fetch a property
// can be fast. Generally, think of it like this:
// fn callback(handle: *const v8.FunctionCallbackInfo) callconv(.c) void {
// const js_obj = info.getThis();
// const cached_value = js_obj.getFromCache("Nodes.childNodes");
// info.returnValue().set(cached_value);
// }
//
// That above pseudocode snippet is largely what this respondFromCache is doing.
// But on miss, it's also setting the `cache_state` with all of the data it
// got checking the cache, so that, once we get the value from our Zig code,
// it's quick to store in the v8::Object for subsequent calls.
fn respondFromCache(comptime cache: Opts.Caching, ctx: *Context, v8_context: *const v8.Context, info: FunctionCallbackInfo, cache_state: *CacheState) bool {
const js_this = info.getThis();
const return_value = info.getReturnValue();
switch (cache) {
.internal => |idx| {
if (v8.v8__Object__GetInternalField(js_this, idx)) |cached| {
// means we can't cache undefined, since we can't tell the
// difference between "it isn't in the cache" and "it's
// in the cache with a valud of undefined"
if (!v8.v8__Value__IsUndefined(cached)) {
return_value.set(cached);
return true;
}
}
// store this so that we can quickly save the result into the cache
cache_state.* = .{
.js_this = js_this,
.v8_context = v8_context,
.mode = .{ .internal = idx },
};
},
.private => |private_symbol| {
const global_handle = &@field(ctx.env.private_symbols, private_symbol).handle;
const private_key: *const v8.Private = v8.v8__Global__Get(global_handle, ctx.isolate.handle).?;
if (v8.v8__Object__GetPrivate(js_this, v8_context, private_key)) |cached| {
// This means we can't cache "undefined", since we can't tell
// the difference between a (a) undefined == not in the cache
// and (b) undefined == the cache value. If this becomes
// important, we can check HasPrivate first. But that requires
// calling HasPrivate then GetPrivate.
if (!v8.v8__Value__IsUndefined(cached)) {
return_value.set(cached);
return true;
}
}
// store this so that we can quickly save the result into the cache
cache_state.* = .{
.js_this = js_this,
.v8_context = v8_context,
.mode = .{ .private = private_key },
};
},
}
// cache miss
return false;
}
const CacheState = struct {
js_this: *const v8.Object,
v8_context: *const v8.Context,
mode: union(enum) {
internal: u8,
private: *const v8.Private,
},
pub fn save(self: *const CacheState, comptime cache: Opts.Caching, js_value: js.Value) void {
if (comptime cache == .internal) {
v8.v8__Object__SetInternalField(self.js_this, self.mode.internal, js_value.handle);
} else {
var out: v8.MaybeBool = undefined;
v8.v8__Object__SetPrivate(self.js_this, self.v8_context, self.mode.private, js_value.handle, &out);
}
}
};
};
// If we call a method in javascript: cat.lives('nine');
//
// Then we'd expect a Zig function with 2 parameters: a self and the string.
// In this case, offset == 1. Offset is always 1 for setters or methods.
//
// Offset is always 0 for constructors.
//
// For constructors, setters and methods, we can further increase offset + 1
// if the first parameter is an instance of Page.
//
// Finally, if the JS function is called with _more_ parameters and
// the last parameter in Zig is an array, we'll try to slurp the additional
// parameters into the array.
fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: FunctionCallbackInfo) !ParameterTypes(F) {
var args: ParameterTypes(F) = undefined;
const params = @typeInfo(F).@"fn".params[offset..];
// Except for the constructor, the first parameter is always `self`
// This isn't something we'll bind from JS, so skip it.
const params_to_map = blk: {
if (params.len == 0) {
return args;
}
// If the last parameter is the Page, set it, and exclude it
// from our params slice, because we don't want to bind it to
// a JS argument
if (comptime isPage(params[params.len - 1].type.?)) {
@field(args, tupleFieldName(params.len - 1 + offset)) = local.ctx.page;
break :blk params[0 .. params.len - 1];
}
// we have neither a Page nor a JsObject. All params must be
// bound to a JavaScript value.
break :blk params;
};
if (params_to_map.len == 0) {
return args;
}
const js_parameter_count = info.length();
const last_js_parameter = params_to_map.len - 1;
var is_variadic = false;
{
// This is going to get complicated. If the last Zig parameter
// is a slice AND the corresponding javascript parameter is
// NOT an an array, then we'll treat it as a variadic.
const last_parameter_type = params_to_map[params_to_map.len - 1].type.?;
const last_parameter_type_info = @typeInfo(last_parameter_type);
if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) {
const slice_type = last_parameter_type_info.pointer.child;
const corresponding_js_value = info.getArg(@intCast(last_js_parameter), local);
if (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8) {
is_variadic = true;
if (js_parameter_count == 0) {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
} else if (js_parameter_count >= params_to_map.len) {
const arr = try local.call_arena.alloc(last_parameter_type_info.pointer.child, js_parameter_count - params_to_map.len + 1);
for (arr, last_js_parameter..) |*a, i| {
a.* = try local.jsValueToZig(slice_type, info.getArg(@intCast(i), local));
}
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = arr;
} else {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
}
}
}
}
inline for (params_to_map, 0..) |param, i| {
const field_index = comptime i + offset;
if (comptime i == params_to_map.len - 1) {
if (is_variadic) {
break;
}
}
if (comptime isPage(param.type.?)) {
@compileError("Page must be the last parameter (or 2nd last if there's a JsThis): " ++ @typeName(F));
} else if (i >= js_parameter_count) {
if (@typeInfo(param.type.?) != .optional) {
return error.InvalidArgument;
}
@field(args, tupleFieldName(field_index)) = null;
} else {
const js_val = info.getArg(@intCast(i), local);
@field(args, tupleFieldName(field_index)) = local.jsValueToZig(param.type.?, js_val) catch {
return error.InvalidArgument;
};
}
}
return args;
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,11 @@ handle: v8.HandleScope,
// 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);
self.initWithIsolateHandle(isolate.handle);
}
pub fn initWithIsolateHandle(self: *HandleScope, isolate: *v8.Isolate) void {
v8.v8__HandleScope__CONSTRUCT(&self.handle, isolate);
}
pub fn deinit(self: *HandleScope) void {

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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,33 +16,27 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("js.zig");
const v8 = js.v8;
pub fn Global(comptime T: type) type {
const H = @FieldType(T, "handle");
const Private = @This();
return struct {
global: v8.Global,
// Unlike most types, we always store the Private as a Global. It makes more
// sense for this type given how it's used.
handle: v8.Global,
const Self = @This();
pub fn init(isolate: *v8.Isolate, name: []const u8) Private {
const v8_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
const private_handle = v8.v8__Private__New(isolate, v8_name);
pub fn init(isolate: *v8.Isolate, handle: H) Self {
var global: v8.Global = undefined;
v8.v8__Global__New(isolate, handle, &global);
return .{
.global = global,
};
}
var global: v8.Global = undefined;
v8.v8__Global__New(isolate, private_handle, &global);
pub fn deinit(self: *Self) void {
v8.v8__Global__Reset(&self.global);
}
pub fn local(self: *const Self) H {
return @ptrCast(@alignCast(@as(*const anyopaque, @ptrFromInt(self.global.data_ptr))));
}
return .{
.handle = global,
};
}
pub fn deinit(self: *Private) void {
v8.v8__Global__Reset(&self.handle);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,6 @@ const bridge = @import("bridge.zig");
const log = @import("../../log.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
const Window = @import("../webapi/Window.zig");
const v8 = js.v8;
const JsApis = bridge.JsApis;
@@ -114,20 +113,6 @@ fn isValid(self: Snapshot) bool {
return v8.v8__StartupData__IsValid(self.startup_data);
}
pub fn createGlobalTemplate(isolate: *v8.Isolate, templates: anytype) *const v8.ObjectTemplate {
// Set up the global template to inherit from Window's template
// This way the global object gets all Window properties through inheritance
const js_global = v8.v8__FunctionTemplate__New__DEFAULT(isolate);
const window_name = v8.v8__String__NewFromUtf8(isolate, "Window", v8.kNormal, 6);
v8.v8__FunctionTemplate__SetClassName(js_global, window_name);
// Find Window in JsApis by name (avoids circular import)
const window_index = comptime bridge.JsApiLookup.getId(Window.JsApi);
v8.v8__FunctionTemplate__Inherit(js_global, templates[window_index]);
return v8.v8__FunctionTemplate__InstanceTemplate(js_global).?;
}
pub fn create() !Snapshot {
var external_references = collectExternalReferences();
@@ -169,8 +154,7 @@ pub fn create() !Snapshot {
// Set up the global template to inherit from Window's template
// This way the global object gets all Window properties through inheritance
const global_template = createGlobalTemplate(isolate, templates[0..]);
const context = v8.v8__Context__New(isolate, global_template, null);
const context = v8.v8__Context__New(isolate, null, null);
v8.v8__Context__Enter(context);
defer v8.v8__Context__Exit(context);
@@ -218,12 +202,16 @@ pub fn create() !Snapshot {
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);
v8.v8__Object__DefineOwnProperty(global_obj, context, illegal_class_name, func, 0, &maybe_result2);
} else {
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);
var properties: v8.PropertyAttribute = v8.None;
if (@hasDecl(JsApi.Meta, "enumerable") and JsApi.Meta.enumerable == false) {
properties |= v8.DontEnum;
}
v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, properties, &maybe_result);
}
}
}
@@ -277,12 +265,26 @@ pub fn create() !Snapshot {
};
}
// Helper to check if a JsApi has a NamedIndexed handler
fn hasNamedIndexedGetter(comptime JsApi: type) bool {
const declarations = @typeInfo(JsApi).@"struct".decls;
inline for (declarations) |d| {
const value = @field(JsApi, d.name);
const T = @TypeOf(value);
if (T == bridge.NamedIndexed) {
return true;
}
}
return false;
}
// Count total callbacks needed for external_references array
fn countExternalReferences() comptime_int {
@setEvalBranchQuota(100_000);
// +1 for the illegal constructor callback
var count: comptime_int = 1;
var has_non_template_property: bool = false;
inline for (JsApis) |JsApi| {
// Constructor (only if explicit)
@@ -305,6 +307,10 @@ fn countExternalReferences() comptime_int {
if (value.setter != null) count += 1; // setter
} else if (T == bridge.Function) {
count += 1;
} else if (T == bridge.Property) {
if (value.template == false) {
has_non_template_property = true;
}
} else if (T == bridge.Iterator) {
count += 1;
} else if (T == bridge.Indexed) {
@@ -317,6 +323,19 @@ fn countExternalReferences() comptime_int {
}
}
if (has_non_template_property) {
count += 1;
}
// In debug mode, add unknown property callbacks for types without NamedIndexed
if (comptime IS_DEBUG) {
inline for (JsApis) |JsApi| {
if (!hasNamedIndexedGetter(JsApi)) {
count += 1;
}
}
}
return count + 1; // +1 for null terminator
}
@@ -327,6 +346,8 @@ fn collectExternalReferences() [countExternalReferences()]isize {
references[idx] = @bitCast(@intFromPtr(&illegalConstructorCallback));
idx += 1;
var has_non_template_property = false;
inline for (JsApis) |JsApi| {
if (@hasDecl(JsApi, "constructor")) {
references[idx] = @bitCast(@intFromPtr(JsApi.constructor.func));
@@ -352,6 +373,10 @@ fn collectExternalReferences() [countExternalReferences()]isize {
} else if (T == bridge.Function) {
references[idx] = @bitCast(@intFromPtr(value.func));
idx += 1;
} else if (T == bridge.Property) {
if (value.template == false) {
has_non_template_property = true;
}
} else if (T == bridge.Iterator) {
references[idx] = @bitCast(@intFromPtr(value.func));
idx += 1;
@@ -373,6 +398,21 @@ fn collectExternalReferences() [countExternalReferences()]isize {
}
}
if (has_non_template_property) {
references[idx] = @bitCast(@intFromPtr(&bridge.Property.getter));
idx += 1;
}
// In debug mode, collect unknown property callbacks for types without NamedIndexed
if (comptime IS_DEBUG) {
inline for (JsApis) |JsApi| {
if (!hasNamedIndexedGetter(JsApi)) {
references[idx] = @bitCast(@intFromPtr(bridge.unknownObjectPropertyCallback(JsApi)));
idx += 1;
}
}
}
return references;
}
@@ -393,9 +433,12 @@ fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionT
};
const template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?);
if (!@hasDecl(JsApi.Meta, "empty_with_no_proto")) {
const instance_template = v8.v8__FunctionTemplate__InstanceTemplate(template);
v8.v8__ObjectTemplate__SetInternalFieldCount(instance_template, 1);
{
const internal_field_count = comptime countInternalFields(JsApi);
if (internal_field_count > 0) {
const instance_template = v8.v8__FunctionTemplate__InstanceTemplate(template);
v8.v8__ObjectTemplate__SetInternalFieldCount(instance_template, internal_field_count);
}
}
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));
@@ -403,12 +446,51 @@ fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionT
return template;
}
pub fn countInternalFields(comptime JsApi: type) u8 {
var last_used_id = 0;
var cache_count: u8 = 0;
inline for (@typeInfo(JsApi).@"struct".decls) |d| {
const name: [:0]const u8 = d.name;
const value = @field(JsApi, name);
const definition = @TypeOf(value);
switch (definition) {
inline bridge.Accessor, bridge.Function => {
const cache = value.cache orelse continue;
if (cache != .internal) {
continue;
}
// We assert that they are declared in-order. This isn't necessary
// but I don't want to do anything fancy to look for gaps or
// duplicates.
const internal_id = cache.internal;
if (internal_id != last_used_id + 1) {
@compileError(@typeName(JsApi) ++ "." ++ name ++ " has a non-monotonic cache index");
}
last_used_id = internal_id;
cache_count += 1; // this is just last_used, but it's more explicit this way
},
else => {},
}
}
if (@hasDecl(JsApi.Meta, "empty_with_no_proto")) {
return cache_count;
}
// we need cache_count internal fields, + 1 for the TAO pointer (the v8 -> Zig)
// mapping) itself.
return cache_count + 1;
}
// Attaches JsApi members to the prototype template (normal case)
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;
@@ -418,7 +500,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
switch (definition) {
bridge.Accessor => {
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, value.getter).?);
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.getter }).?);
if (value.setter == null) {
if (value.static) {
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback);
@@ -429,12 +511,12 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
if (comptime IS_DEBUG) {
std.debug.assert(value.static == false);
}
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, value.setter.?).?);
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?);
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(target, js_name, getter_callback, setter_callback);
}
},
bridge.Function => {
const function_template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, value.func).?);
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func, .length = value.arity }).?);
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
if (value.static) {
v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
@@ -469,9 +551,10 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
};
v8.v8__ObjectTemplate__SetNamedHandler(instance, &configuration);
has_named_index_getter = true;
},
bridge.Iterator => {
const function_template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, value.func).?);
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?);
const js_name = if (value.async)
v8.v8__Symbol__GetAsyncIterator(isolate)
else
@@ -479,17 +562,29 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
v8.v8__Template__Set(@ptrCast(target), js_name, @ptrCast(function_template), v8.None);
},
bridge.Property => {
// simpleZigValueToJs now returns raw handle directly
const js_value = switch (value) {
.int => |v| js.simpleZigValueToJs(.{ .handle = isolate }, v, true, false),
const js_value = switch (value.value) {
.null => js.simpleZigValueToJs(.{ .handle = isolate }, null, true, false),
inline .bool, .int, .float, .string => |v| js.simpleZigValueToJs(.{ .handle = isolate }, v, true, false),
};
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
// apply it both to the type itself
v8.v8__Template__Set(@ptrCast(template), js_name, js_value, v8.ReadOnly + v8.DontDelete);
// and to instances of the type
v8.v8__Template__Set(@ptrCast(target), js_name, js_value, v8.ReadOnly + v8.DontDelete);
if (value.template == false) {
// not defined on the template, only on the instance. This
// is like an Accessor, but because the value is known at
// compile time, we skip _a lot_ of code and quickly return
// the hard-coded value
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = bridge.Property.getter,
.data = js_value,
.side_effect_type = v8.kSideEffectType_HasSideEffectToReceiver,
}));
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(target, js_name, getter_callback);
} else {
// apply it both to the type itself
v8.v8__Template__Set(@ptrCast(template), js_name, js_value, v8.ReadOnly + v8.DontDelete);
// and to instances of the type
v8.v8__Template__Set(@ptrCast(target), js_name, js_value, v8.ReadOnly + v8.DontDelete);
}
},
bridge.Constructor => {}, // already handled in generateConstructor
else => {},
@@ -506,6 +601,23 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
const js_value = v8.v8__String__NewFromUtf8(isolate, JsApi.Meta.name.ptr, v8.kNormal, @intCast(JsApi.Meta.name.len));
v8.v8__Template__Set(@ptrCast(instance), js_name, js_value, v8.ReadOnly + v8.DontDelete);
}
if (comptime IS_DEBUG) {
if (!has_named_index_getter) {
var configuration: v8.NamedPropertyHandlerConfiguration = .{
.getter = bridge.unknownObjectPropertyCallback(JsApi),
.setter = null,
.query = null,
.deleter = null,
.enumerator = null,
.definer = null,
.descriptor = null,
.data = null,
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
};
v8.v8__ObjectTemplate__SetNamedHandler(instance, &configuration);
}
}
}
fn protoIndexLookup(comptime JsApi: type) ?bridge.JsApiLookup.BackingInt {

View File

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

View File

@@ -0,0 +1,129 @@
// 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);
// 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 tao_ptr = v8.v8__Object__GetAlignedPointerFromInternalField(js_obj_handle, 0).?;
const tao: *TaggedOpaque = @ptrCast(@alignCast(tao_ptr));
const expected_type_index = bridge.JsApiLookup.getId(JsApi);
const prototype_chain = tao.prototype_chain[0..tao.prototype_len];
if (prototype_chain[0].index == expected_type_index) {
return @ptrCast(@alignCast(tao.value));
}
// Ok, let's walk up the chain
var ptr = @intFromPtr(tao.value);
for (prototype_chain[1..]) |proto| {
ptr += proto.offset; // the offset to the _proto field
const proto_ptr: **anyopaque = @ptrFromInt(ptr);
if (proto.index == expected_type_index) {
return @ptrCast(@alignCast(proto_ptr.*));
}
ptr = @intFromPtr(proto_ptr.*);
}
return error.InvalidArgument;
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

506
src/browser/markdown.zig Normal file
View File

@@ -0,0 +1,506 @@
// 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 Page = @import("Page.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
pub const Opts = struct {
// Options for future customization (e.g., dialect)
};
const State = struct {
const ListType = enum { ordered, unordered };
const ListState = struct {
type: ListType,
index: usize,
};
list_depth: usize = 0,
list_stack: [32]ListState = undefined,
in_pre: bool = false,
pre_node: ?*Node = null,
in_code: bool = false,
in_table: bool = false,
table_row_index: usize = 0,
table_col_count: usize = 0,
last_char_was_newline: bool = true,
};
fn isBlock(tag: Element.Tag) bool {
return switch (tag) {
.p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => true,
else => false,
};
}
fn shouldAddSpacing(tag: Element.Tag) bool {
return switch (tag) {
.p, .h1, .h2, .h3, .h4, .h5, .h6, .blockquote, .pre, .table => true,
else => false,
};
}
fn ensureNewline(state: *State, writer: *std.Io.Writer) !void {
if (!state.last_char_was_newline) {
try writer.writeByte('\n');
state.last_char_was_newline = true;
}
}
pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
_ = opts;
var state = State{};
try render(node, &state, writer, page);
if (!state.last_char_was_newline) {
try writer.writeByte('\n');
}
}
fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void {
switch (node._type) {
.document, .document_fragment => {
try renderChildren(node, state, writer, page);
},
.element => |el| {
try renderElement(el, state, writer, page);
},
.cdata => |cd| {
if (node.is(Node.CData.Text)) |_| {
var text = cd.getData();
if (state.in_pre) {
if (state.pre_node) |pre| {
if (node.parentNode() == pre and node.nextSibling() == null) {
text = std.mem.trimRight(u8, text, " \t\r\n");
}
}
}
try renderText(text, state, writer);
}
},
else => {}, // Ignore other node types
}
}
fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) !void {
var it = parent.childrenIterator();
while (it.next()) |child| {
try render(child, state, writer, page);
}
}
fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) !void {
const tag = el.getTag();
// Skip hidden/metadata elements
switch (tag) {
.script, .style, .noscript, .template, .head, .meta, .link, .title, .svg => return,
else => {},
}
// --- Opening Tag Logic ---
// Ensure block elements start on a new line (double newline for paragraphs etc)
if (isBlock(tag)) {
if (!state.in_table) {
try ensureNewline(state, writer);
if (shouldAddSpacing(tag)) {
// Add an extra newline for spacing between blocks
try writer.writeByte('\n');
}
}
} else if (tag == .li or tag == .tr) {
try ensureNewline(state, writer);
}
// Prefixes
switch (tag) {
.h1 => try writer.writeAll("# "),
.h2 => try writer.writeAll("## "),
.h3 => try writer.writeAll("### "),
.h4 => try writer.writeAll("#### "),
.h5 => try writer.writeAll("##### "),
.h6 => try writer.writeAll("###### "),
.ul => {
if (state.list_depth < state.list_stack.len) {
state.list_stack[state.list_depth] = .{ .type = .unordered, .index = 0 };
state.list_depth += 1;
}
},
.ol => {
if (state.list_depth < state.list_stack.len) {
state.list_stack[state.list_depth] = .{ .type = .ordered, .index = 1 };
state.list_depth += 1;
}
},
.li => {
const indent = if (state.list_depth > 0) state.list_depth - 1 else 0;
for (0..indent) |_| try writer.writeAll(" ");
if (state.list_depth > 0) {
const current_list = &state.list_stack[state.list_depth - 1];
if (current_list.type == .ordered) {
try writer.print("{d}. ", .{current_list.index});
current_list.index += 1;
} else {
try writer.writeAll("- ");
}
} else {
try writer.writeAll("- ");
}
state.last_char_was_newline = false;
},
.table => {
state.in_table = true;
state.table_row_index = 0;
state.table_col_count = 0;
},
.tr => {
state.table_col_count = 0;
try writer.writeByte('|');
},
.td, .th => {
// Note: leading pipe handled by previous cell closing or tr opening
state.last_char_was_newline = false;
try writer.writeByte(' ');
},
.blockquote => {
try writer.writeAll("> ");
state.last_char_was_newline = false;
},
.pre => {
try writer.writeAll("```\n");
state.in_pre = true;
state.pre_node = el.asNode();
state.last_char_was_newline = true;
},
.code => {
if (!state.in_pre) {
try writer.writeByte('`');
state.in_code = true;
state.last_char_was_newline = false;
}
},
.b, .strong => {
try writer.writeAll("**");
state.last_char_was_newline = false;
},
.i, .em => {
try writer.writeAll("*");
state.last_char_was_newline = false;
},
.s, .del => {
try writer.writeAll("~~");
state.last_char_was_newline = false;
},
.hr => {
try writer.writeAll("---\n");
state.last_char_was_newline = true;
return; // Void element
},
.br => {
if (state.in_table) {
try writer.writeByte(' ');
} else {
try writer.writeByte('\n');
state.last_char_was_newline = true;
}
return; // Void element
},
.img => {
try writer.writeAll("![");
if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| {
try escapeMarkdown(writer, alt);
}
try writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
try writer.writeAll(src);
}
try writer.writeAll(")");
state.last_char_was_newline = false;
return; // Treat as void
},
.anchor => {
try writer.writeByte('[');
try renderChildren(el.asNode(), state, writer, page);
try writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("href"))) |href| {
try writer.writeAll(href);
}
try writer.writeByte(')');
state.last_char_was_newline = false;
return;
},
.input => {
if (el.getAttributeSafe(comptime .wrap("type"))) |type_attr| {
if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) {
if (el.getAttributeSafe(comptime .wrap("checked"))) |_| {
try writer.writeAll("[x] ");
} else {
try writer.writeAll("[ ] ");
}
state.last_char_was_newline = false;
}
}
return;
},
else => {},
}
// --- Render Children ---
try renderChildren(el.asNode(), state, writer, page);
// --- Closing Tag Logic ---
// Suffixes
switch (tag) {
.pre => {
if (!state.last_char_was_newline) {
try writer.writeByte('\n');
}
try writer.writeAll("```\n");
state.in_pre = false;
state.pre_node = null;
state.last_char_was_newline = true;
},
.code => {
if (!state.in_pre) {
try writer.writeByte('`');
state.in_code = false;
state.last_char_was_newline = false;
}
},
.b, .strong => {
try writer.writeAll("**");
state.last_char_was_newline = false;
},
.i, .em => {
try writer.writeAll("*");
state.last_char_was_newline = false;
},
.s, .del => {
try writer.writeAll("~~");
state.last_char_was_newline = false;
},
.blockquote => {},
.ul, .ol => {
if (state.list_depth > 0) state.list_depth -= 1;
},
.table => {
state.in_table = false;
},
.tr => {
try writer.writeByte('\n');
if (state.table_row_index == 0) {
try writer.writeByte('|');
var i: usize = 0;
while (i < state.table_col_count) : (i += 1) {
try writer.writeAll("---|");
}
try writer.writeByte('\n');
}
state.table_row_index += 1;
state.last_char_was_newline = true;
},
.td, .th => {
try writer.writeAll(" |");
state.table_col_count += 1;
state.last_char_was_newline = false;
},
else => {},
}
// Post-block newlines
if (isBlock(tag)) {
if (!state.in_table) {
try ensureNewline(state, writer);
}
}
}
fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) !void {
if (text.len == 0) return;
if (state.in_pre) {
try writer.writeAll(text);
if (text.len > 0 and text[text.len - 1] == '\n') {
state.last_char_was_newline = true;
} else {
state.last_char_was_newline = false;
}
return;
}
// Check for pure whitespace
const is_all_whitespace = for (text) |c| {
if (!std.ascii.isWhitespace(c)) break false;
} else true;
if (is_all_whitespace) {
if (!state.last_char_was_newline) {
try writer.writeByte(' ');
}
return;
}
// Collapse whitespace
var it = std.mem.tokenizeAny(u8, text, " \t\n\r");
var first = true;
while (it.next()) |word| {
if (first) {
if (!state.last_char_was_newline) {
if (text.len > 0 and std.ascii.isWhitespace(text[0])) {
try writer.writeByte(' ');
}
}
} else {
try writer.writeByte(' ');
}
try escapeMarkdown(writer, word);
state.last_char_was_newline = false;
first = false;
}
// Handle trailing whitespace from the original text
if (!first and !state.last_char_was_newline) {
if (text.len > 0 and std.ascii.isWhitespace(text[text.len - 1])) {
try writer.writeByte(' ');
}
}
}
fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void {
for (text) |c| {
switch (c) {
'\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => {
try writer.writeByte('\\');
try writer.writeByte(c);
},
else => try writer.writeByte(c),
}
}
}
fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
const testing = @import("../testing.zig");
const page = try testing.test_session.createPage();
defer testing.test_session.removePage();
const doc = page.window._document;
const div = try doc.createElement("div", null, page);
try page.parseHtmlAsChildren(div.asNode(), html);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try dump(div.asNode(), .{}, &aw.writer, page);
try testing.expectString(expected, aw.written());
}
test "markdown: basic" {
try testMarkdownHTML("Hello world", "Hello world\n");
}
test "markdown: whitespace" {
try testMarkdownHTML("<span>A</span> <span>B</span>", "A B\n");
}
test "markdown: escaping" {
try testMarkdownHTML("<p># Not a header</p>", "\n\\# Not a header\n");
}
test "markdown: strikethrough" {
try testMarkdownHTML("<s>deleted</s>", "~~deleted~~\n");
}
test "markdown: task list" {
try testMarkdownHTML(
\\<input type="checkbox" checked><input type="checkbox">
, "[x] [ ] \n");
}
test "markdown: ordered list" {
try testMarkdownHTML(
\\<ol><li>First</li><li>Second</li></ol>
, "1. First\n2. Second\n");
}
test "markdown: table" {
try testMarkdownHTML(
\\<table><thead><tr><th>Head 1</th><th>Head 2</th></tr></thead>
\\<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr></tbody></table>
,
\\
\\| Head 1 | Head 2 |
\\|---|---|
\\| Cell 1 | Cell 2 |
\\
);
}
test "markdown: nested lists" {
try testMarkdownHTML(
\\<ul><li>Parent<ul><li>Child</li></ul></li></ul>
,
\\- Parent
\\ - Child
\\
);
}
test "markdown: blockquote" {
try testMarkdownHTML("<blockquote>Hello world</blockquote>", "\n> Hello world\n");
}
test "markdown: links" {
try testMarkdownHTML("<a href=\"https://lightpanda.io\">Lightpanda</a>", "[Lightpanda](https://lightpanda.io)\n");
}
test "markdown: images" {
try testMarkdownHTML("<img src=\"logo.png\" alt=\"Logo\">", "![Logo](logo.png)\n");
}
test "markdown: headings" {
try testMarkdownHTML("<h1>Title</h1><h2>Subtitle</h2>",
\\
\\# Title
\\
\\## Subtitle
\\
);
}
test "markdown: code" {
try testMarkdownHTML(
\\<p>Use git push</p>
\\<pre><code>line 1
\\line 2</code></pre>
,
\\
\\Use git push
\\
\\```
\\line 1
\\line 2
\\```
\\
);
}

View File

@@ -24,6 +24,7 @@ const Page = @import("../Page.zig");
const Node = @import("../webapi/Node.zig");
const Element = @import("../webapi/Element.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
pub const ParsedNode = struct {
node: *Node,
@@ -373,6 +374,17 @@ fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !
switch (node_or_text.toUnion()) {
.node => |cpn| {
const child = getNode(cpn);
if (child._parent) |previous_parent| {
// html5ever says this can't happen, but we might be screwing up
// the node on our side. We shouldn't be, but we're seeing this
// in the wild, and I'm not sure why. In debug, let's crash so
// we can try to figure it out. In release, let's disconnect
// the child first.
if (comptime IS_DEBUG) {
unreachable;
}
self.page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent.isConnected() });
}
try self.page.appendNew(parent, .{ .node = child });
},
.text => |txt| try self.page.appendNew(parent, .{ .text = txt }),
@@ -409,7 +421,16 @@ fn appendBeforeSiblingCallback(ctx: *anyopaque, sibling_ref: *anyopaque, node_or
fn _appendBeforeSiblingCallback(self: *Parser, sibling: *Node, node_or_text: h5e.NodeOrText) !void {
const parent = sibling.parentNode() orelse return error.NoParent;
const node: *Node = switch (node_or_text.toUnion()) {
.node => |cpn| getNode(cpn),
.node => |cpn| blk: {
const child = getNode(cpn);
if (child._parent) |previous_parent| {
// A custom element constructor may have inserted the node into the
// DOM before the parser officially places it (e.g. via foster
// parenting). Detach it first so insertNodeRelative's assertion holds.
self.page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent.isConnected() });
}
break :blk child;
},
.text => |txt| try self.page.createTextNode(txt),
};
try self.page.insertNodeRelative(parent, node, .{ .before = sibling }, .{});

View File

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

View File

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

View File

@@ -16,44 +16,44 @@
isRandom(ti8a)
}
// {
// let tu16a = new Uint16Array(100)
// testing.expectEqual(tu16a, crypto.getRandomValues(tu16a))
// isRandom(tu16a)
{
let tu16a = new Uint16Array(100)
testing.expectEqual(tu16a, crypto.getRandomValues(tu16a))
isRandom(tu16a)
// let ti16a = new Int16Array(100)
// testing.expectEqual(ti16a, crypto.getRandomValues(ti16a))
// isRandom(ti16a)
// }
let ti16a = new Int16Array(100)
testing.expectEqual(ti16a, crypto.getRandomValues(ti16a))
isRandom(ti16a)
}
// {
// let tu32a = new Uint32Array(100)
// testing.expectEqual(tu32a, crypto.getRandomValues(tu32a))
// isRandom(tu32a)
{
let tu32a = new Uint32Array(100)
testing.expectEqual(tu32a, crypto.getRandomValues(tu32a))
isRandom(tu32a)
// let ti32a = new Int32Array(100)
// testing.expectEqual(ti32a, crypto.getRandomValues(ti32a))
// isRandom(ti32a)
// }
let ti32a = new Int32Array(100)
testing.expectEqual(ti32a, crypto.getRandomValues(ti32a))
isRandom(ti32a)
}
// {
// let tu64a = new BigUint64Array(100)
// testing.expectEqual(tu64a, crypto.getRandomValues(tu64a))
// isRandom(tu64a)
{
let tu64a = new BigUint64Array(100)
testing.expectEqual(tu64a, crypto.getRandomValues(tu64a))
isRandom(tu64a)
// let ti64a = new BigInt64Array(100)
// testing.expectEqual(ti64a, crypto.getRandomValues(ti64a))
// isRandom(ti64a)
// }
let ti64a = new BigInt64Array(100)
testing.expectEqual(ti64a, crypto.getRandomValues(ti64a))
isRandom(ti64a)
}
</script>
<!-- <script id="randomUUID">
<script id="randomUUID">
const uuid = crypto.randomUUID();
testing.expectEqual('string', typeof uuid);
testing.expectEqual(36, uuid.length);
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>
<script id=SubtleCrypto>
testing.expectEqual(true, crypto.subtle instanceof SubtleCrypto);
@@ -119,3 +119,16 @@
testing.expectEqual(16, sharedKey.byteLength);
});
</script>
<script id="digest">
testing.async(async () => {
async function hash(algo, data) {
const buffer = await window.crypto.subtle.digest(algo, new TextEncoder().encode(data));
return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
}
testing.expectEqual("a6a1e3375239f215f09a156df29c17c7d1ac6722", await hash('sha-1', 'over 9000'));
testing.expectEqual("1bc375bb92459685194dda18a4b835f4e2972ec1bde6d9ab3db53fcc584a6580", await hash('sha-256', 'over 9000'));
testing.expectEqual("a4260d64c2eea9fd30c1f895c5e48a26d817e19d3a700b61b3ce665864ff4b8e012bd357d345aa614c5f642dab865ea1", await hash('sha-384', 'over 9000'));
testing.expectEqual("6cad17e6f3f76680d6dd18ed043b75b4f6e1aa1d08b917294942e882fb6466c3510948c34af8b903ed0725b582b3b39c0e485ae2c1b7dfdb192ee38b79c782b6", await hash('sha-512', 'over 9000'));
});
</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

@@ -119,3 +119,33 @@
}
</script>
<script id="constructor_self_insert_foster_parent">
{
// Regression: custom element constructor inserting itself (via appendChild) during
// innerHTML parsing. When the element is not valid table content, the HTML5 parser
// foster-parents it before the <table> via appendBeforeSiblingCallback. That callback
// previously didn't check for an existing _parent before calling insertNodeRelative,
// causing the "Page.insertNodeRelative parent" assertion to fire.
let constructorCalled = 0;
let container;
class CtorSelfInsert extends HTMLElement {
constructor() {
super();
constructorCalled++;
// Insert self into container so _parent is set before the parser
// officially places this element via appendBeforeSiblingCallback.
if (container) container.appendChild(this);
}
}
customElements.define('ctor-self-insert', CtorSelfInsert);
container = document.createElement('div');
// ctor-self-insert is not valid table content; the parser foster-parents it
// before the <table>, calling appendBeforeSiblingCallback(sibling=table, node=element).
// At that point the element already has _parent=container from the constructor.
container.innerHTML = '<table><ctor-self-insert></ctor-self-insert></table>';
testing.expectEqual(1, constructorCalled);
}
</script>

View File

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

View File

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

View File

@@ -13,6 +13,10 @@
testing.expectEqual(undefined, document.getCurrentScript);
testing.expectEqual("http://127.0.0.1:9582/src/browser/tests/document/document.html", document.URL);
testing.expectEqual(window, document.defaultView);
testing.expectEqual(false, document.hidden);
testing.expectEqual("visible", document.visibilityState);
testing.expectEqual(false, document.prerendering);
testing.expectEqual(undefined, Document.prerendering);
</script>
<script id=headAndbody>
@@ -22,6 +26,7 @@
<script id=documentElement>
testing.expectEqual($('#the_body').parentNode, document.documentElement);
testing.expectEqual(document.documentElement, document.scrollingElement);
</script>
<script id=title>

View File

@@ -81,6 +81,172 @@
</script>
<script id="focusin_focusout_events">
{
const input1 = $('#input1');
const input2 = $('#input2');
if (document.activeElement) {
document.activeElement.blur();
}
let events = [];
input1.addEventListener('focus', () => events.push('focus1'));
input1.addEventListener('focusin', () => events.push('focusin1'));
input1.addEventListener('blur', () => events.push('blur1'));
input1.addEventListener('focusout', () => events.push('focusout1'));
input2.addEventListener('focus', () => events.push('focus2'));
input2.addEventListener('focusin', () => events.push('focusin2'));
// Focus input1 — should fire focus then focusin
input1.focus();
testing.expectEqual('focus1,focusin1', events.join(','));
// Focus input2 — should fire blur, focusout on input1, then focus, focusin on input2
events = [];
input2.focus();
testing.expectEqual('blur1,focusout1,focus2,focusin2', events.join(','));
}
</script>
<script id="focusin_bubbles">
{
const input1 = $('#input1');
if (document.activeElement) {
document.activeElement.blur();
}
let bodyFocusin = 0;
let bodyFocus = 0;
document.body.addEventListener('focusin', () => bodyFocusin++);
document.body.addEventListener('focus', () => bodyFocus++);
input1.focus();
// focusin should bubble to body, focus should not
testing.expectEqual(1, bodyFocusin);
testing.expectEqual(0, bodyFocus);
}
</script>
<script id="focusout_bubbles">
{
const input1 = $('#input1');
input1.focus();
let bodyFocusout = 0;
let bodyBlur = 0;
document.body.addEventListener('focusout', () => bodyFocusout++);
document.body.addEventListener('blur', () => bodyBlur++);
input1.blur();
// focusout should bubble to body, blur should not
testing.expectEqual(1, bodyFocusout);
testing.expectEqual(0, bodyBlur);
}
</script>
<script id="focus_relatedTarget">
{
const input1 = $('#input1');
const input2 = $('#input2');
if (document.activeElement) {
document.activeElement.blur();
}
let focusRelated = null;
let blurRelated = null;
let focusinRelated = null;
let focusoutRelated = null;
input1.addEventListener('blur', (e) => { blurRelated = e.relatedTarget; });
input1.addEventListener('focusout', (e) => { focusoutRelated = e.relatedTarget; });
input2.addEventListener('focus', (e) => { focusRelated = e.relatedTarget; });
input2.addEventListener('focusin', (e) => { focusinRelated = e.relatedTarget; });
input1.focus();
input2.focus();
// blur/focusout on input1 should have relatedTarget = input2 (gaining focus)
testing.expectEqual(input2, blurRelated);
testing.expectEqual(input2, focusoutRelated);
// focus/focusin on input2 should have relatedTarget = input1 (losing focus)
testing.expectEqual(input1, focusRelated);
testing.expectEqual(input1, focusinRelated);
}
</script>
<script id="blur_relatedTarget_null">
{
const btn = $('#btn1');
btn.focus();
let blurRelated = 'not_set';
btn.addEventListener('blur', (e) => { blurRelated = e.relatedTarget; });
btn.blur();
// blur without moving to another element should have relatedTarget = null
testing.expectEqual(null, blurRelated);
}
</script>
<script id="focus_event_properties">
{
const input1 = $('#input1');
const input2 = $('#input2');
if (document.activeElement) {
document.activeElement.blur();
}
let focusEvent = null;
let focusinEvent = null;
let blurEvent = null;
let focusoutEvent = null;
input1.addEventListener('blur', (e) => { blurEvent = e; });
input1.addEventListener('focusout', (e) => { focusoutEvent = e; });
input2.addEventListener('focus', (e) => { focusEvent = e; });
input2.addEventListener('focusin', (e) => { focusinEvent = e; });
input1.focus();
input2.focus();
// All four should be FocusEvent instances
testing.expectEqual(true, blurEvent instanceof FocusEvent);
testing.expectEqual(true, focusoutEvent instanceof FocusEvent);
testing.expectEqual(true, focusEvent instanceof FocusEvent);
testing.expectEqual(true, focusinEvent instanceof FocusEvent);
// All four should be composed per spec
testing.expectEqual(true, blurEvent.composed);
testing.expectEqual(true, focusoutEvent.composed);
testing.expectEqual(true, focusEvent.composed);
testing.expectEqual(true, focusinEvent.composed);
// None should be cancelable
testing.expectEqual(false, blurEvent.cancelable);
testing.expectEqual(false, focusoutEvent.cancelable);
testing.expectEqual(false, focusEvent.cancelable);
testing.expectEqual(false, focusinEvent.cancelable);
// blur/focus don't bubble, focusin/focusout do
testing.expectEqual(false, blurEvent.bubbles);
testing.expectEqual(true, focusoutEvent.bubbles);
testing.expectEqual(false, focusEvent.bubbles);
testing.expectEqual(true, focusinEvent.bubbles);
}
</script>
<script id="focus_disconnected">
{
const focused = document.activeElement;
@@ -88,3 +254,46 @@
testing.expectEqual(focused, document.activeElement);
}
</script>
<script id="click_focuses_element">
{
const input1 = $('#input1');
const input2 = $('#input2');
if (document.activeElement) {
document.activeElement.blur();
}
let focusCount = 0;
let blurCount = 0;
input1.addEventListener('focus', () => focusCount++);
input1.addEventListener('blur', () => blurCount++);
input2.addEventListener('focus', () => focusCount++);
// Click input1 — should focus it and fire focus event
input1.click();
testing.expectEqual(input1, document.activeElement);
testing.expectEqual(1, focusCount);
testing.expectEqual(0, blurCount);
// Click input2 — should move focus, fire blur on input1 and focus on input2
input2.click();
testing.expectEqual(input2, document.activeElement);
testing.expectEqual(2, focusCount);
testing.expectEqual(1, blurCount);
}
</script>
<script id="click_focuses_button">
{
const btn = $('#btn1');
if (document.activeElement) {
document.activeElement.blur();
}
btn.click();
testing.expectEqual(btn, document.activeElement);
}
</script>

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);
@@ -269,3 +270,36 @@
testing.expectEqual('rect', document.querySelector('svg g rect').tagName);
}
</script>
<script id=special>
testing.expectEqual(null, document.querySelector('\\'));
testing.expectEqual(null, document.querySelector('div\\'));
testing.expectEqual(null, document.querySelector('.test-class\\'));
testing.expectEqual(null, document.querySelector('#byId\\'));
</script>
<div class="café">Non-ASCII class 1</div>
<div class="日本語">Non-ASCII class 2</div>
<span id="niño">Non-ASCII ID 1</span>
<p id="🎨">Non-ASCII ID 2</p>
<script id=nonAsciiSelectors>
testing.expectEqual('Non-ASCII class 1', document.querySelector('.café').textContent);
testing.expectEqual('Non-ASCII class 2', document.querySelector('.日本語').textContent);
testing.expectEqual('Non-ASCII ID 1', document.querySelector('#niño').textContent);
testing.expectEqual('Non-ASCII ID 2', document.querySelector('#🎨').textContent);
testing.expectEqual('Non-ASCII class 1', document.querySelector('div.café').textContent);
testing.expectEqual('Non-ASCII ID 1', document.querySelector('span#niño').textContent);
</script>
<span id=".,:!">Punctuation test</span>
<script id=escapedPunctuation>
{
// Test escaped punctuation in ID selectors
testing.expectEqual('Punctuation test', document.querySelector('#\\.\\,\\:\\!').textContent);
}
</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

@@ -93,6 +93,29 @@
}
</script>
<script id=replace_errors>
{
const div = document.createElement('div');
div.className = 'foo bar';
testing.withError((err) => {
testing.expectEqual('SyntaxError', err.name);
}, () => div.classList.replace('', 'baz'));
testing.withError((err) => {
testing.expectEqual('SyntaxError', err.name);
}, () => div.classList.replace('foo', ''));
testing.withError((err) => {
testing.expectEqual('InvalidCharacterError', err.name);
}, () => div.classList.replace('foo bar', 'baz'));
testing.withError((err) => {
testing.expectEqual('InvalidCharacterError', err.name);
}, () => div.classList.replace('foo', 'bar baz'));
}
</script>
<script id=item>
{
const div = document.createElement('div');
@@ -166,6 +189,29 @@
}
</script>
<script id=classList_assignment>
{
const div = document.createElement('div');
// Direct assignment should work (equivalent to classList.value = ...)
div.classList = 'foo bar baz';
testing.expectEqual('foo bar baz', div.className);
testing.expectEqual(3, div.classList.length);
testing.expectEqual(true, div.classList.contains('foo'));
// Assigning again should replace
div.classList = 'qux';
testing.expectEqual('qux', div.className);
testing.expectEqual(1, div.classList.length);
testing.expectEqual(false, div.classList.contains('foo'));
// Empty assignment
div.classList = '';
testing.expectEqual('', div.className);
testing.expectEqual(0, div.classList.length);
}
</script>
<script id=errors>
{
const div = document.createElement('div');

View File

@@ -121,6 +121,29 @@
}
</script>
<script id="propertyAssignment">
{
const div = $('#test-div');
div.style.cssText = '';
// camelCase assignment
div.style.opacity = '0.5';
testing.expectEqual('0.5', div.style.opacity);
// bracket notation assignment
div.style['filter'] = 'blur(5px)';
testing.expectEqual('blur(5px)', div.style.filter);
// numeric value coerced to string
div.style.opacity = 1;
testing.expectEqual('1', div.style.opacity);
// assigning method names should be ignored (not intercepted)
div.style.setProperty('color', 'blue');
testing.expectEqual('blue', div.style.color);
}
</script>
<script id="prototypeChainCheck">
{
const div = $('#test-div');

View File

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id="container" xmlns="http://www.w3.org/1999/xhtml">
<div id="div1">div1</div>
<p id="p1">p1</p>
<div id="div2">div2</div>
</div>
<svg id="svgContainer" xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<circle id="circle1" cx="50" cy="50" r="40"/>
<rect id="rect1" x="10" y="10" width="30" height="30"/>
<circle id="circle2" cx="25" cy="25" r="10"/>
</svg>
<div id="mixed">
<div id="htmlDiv" xmlns="http://www.w3.org/1999/xhtml">HTML div</div>
<svg xmlns="http://www.w3.org/2000/svg">
<circle id="svgCircle" cx="10" cy="10" r="5"/>
</svg>
</div>
<script id=basic>
{
const htmlNS = "http://www.w3.org/1999/xhtml";
const svgNS = "http://www.w3.org/2000/svg";
// Test HTML namespace
const htmlDivs = document.getElementsByTagNameNS(htmlNS, 'div');
testing.expectEqual(true, htmlDivs instanceof HTMLCollection);
testing.expectEqual(5, htmlDivs.length); // container, div1, div2, mixed, htmlDiv
const htmlPs = document.getElementsByTagNameNS(htmlNS, 'p');
testing.expectEqual(1, htmlPs.length);
testing.expectEqual('p1', htmlPs[0].id);
}
</script>
<script id=svgNamespace>
{
const svgNS = "http://www.w3.org/2000/svg";
const circles = document.getElementsByTagNameNS(svgNS, 'circle');
testing.expectEqual(3, circles.length); // circle1, circle2, svgCircle
testing.expectEqual('circle1', circles[0].id);
testing.expectEqual('circle2', circles[1].id);
testing.expectEqual('svgCircle', circles[2].id);
const rects = document.getElementsByTagNameNS(svgNS, 'rect');
testing.expectEqual(1, rects.length);
testing.expectEqual('rect1', rects[0].id);
}
</script>
<script id=nullNamespace>
{
// Null namespace should match elements with null namespace
const nullNsElements = document.getElementsByTagNameNS(null, 'div');
testing.expectEqual(0, nullNsElements.length); // Our divs are in HTML namespace
}
</script>
<script id=wildcardNamespace>
{
// Wildcard namespace "*" should match all namespaces
const allDivs = document.getElementsByTagNameNS('*', 'div');
testing.expectEqual(5, allDivs.length); // All divs regardless of namespace
}
</script>
<script id=wildcardLocalName>
{
const htmlNS = "http://www.w3.org/1999/xhtml";
// Wildcard local name should match all elements in that namespace
const allHtmlElements = document.getElementsByTagNameNS(htmlNS, '*');
testing.expectEqual(true, allHtmlElements.length > 0);
}
</script>
<script id=caseSensitive>
{
const htmlNS = "http://www.w3.org/1999/xhtml";
// getElementsByTagNameNS is case-sensitive for local names
const lowerDivs = document.getElementsByTagNameNS(htmlNS, 'div');
const upperDivs = document.getElementsByTagNameNS(htmlNS, 'DIV');
testing.expectEqual(5, lowerDivs.length);
testing.expectEqual(0, upperDivs.length); // Should be 0 because it's case-sensitive
}
</script>
<script id=unknownNamespace>
{
// Unknown namespace should still work
const unknownNs = document.getElementsByTagNameNS('http://example.com/unknown', 'div');
testing.expectEqual(0, unknownNs.length);
}
</script>
<script id=emptyResult>
{
const htmlNS = "http://www.w3.org/1999/xhtml";
const svgNS = "http://www.w3.org/2000/svg";
testing.expectEqual(0, document.getElementsByTagNameNS(htmlNS, 'nonexistent').length);
testing.expectEqual(0, document.getElementsByTagNameNS(svgNS, 'nonexistent').length);
}
</script>
<script id=elementMethod>
{
const htmlNS = "http://www.w3.org/1999/xhtml";
const container = document.getElementById('container');
// getElementsByTagNameNS on element should only search descendants
const divsInContainer = container.getElementsByTagNameNS(htmlNS, 'div');
testing.expectEqual(2, divsInContainer.length); // div1, div2 (not container itself)
testing.expectEqual('div1', divsInContainer[0].id);
testing.expectEqual('div2', divsInContainer[1].id);
}
</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

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<fieldset id="fs1" disabled name="group1">
<input type="text">
</fieldset>
<fieldset id="fs2">
<input type="text">
</fieldset>
<script id="disabled">
{
const fs1 = document.getElementById('fs1');
testing.expectEqual(true, fs1.disabled);
fs1.disabled = false;
testing.expectEqual(false, fs1.disabled);
const fs2 = document.getElementById('fs2');
testing.expectEqual(false, fs2.disabled);
}
</script>
<script id="name">
{
const fs1 = document.getElementById('fs1');
testing.expectEqual('group1', fs1.name);
fs1.name = 'updated';
testing.expectEqual('updated', fs1.name);
const fs2 = document.getElementById('fs2');
testing.expectEqual('', fs2.name);
}
</script>

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<div id="d1" hidden>Hidden div</div>
<div id="d2">Visible div</div>
<input id="i1" tabindex="5">
<div id="d3">No tabindex</div>
<script id="hidden">
{
const d1 = document.getElementById('d1');
testing.expectEqual(true, d1.hidden);
d1.hidden = false;
testing.expectEqual(false, d1.hidden);
const d2 = document.getElementById('d2');
testing.expectEqual(false, d2.hidden);
d2.hidden = true;
testing.expectEqual(true, d2.hidden);
}
</script>
<script id="tabIndex">
{
const i1 = document.getElementById('i1');
testing.expectEqual(5, i1.tabIndex);
i1.tabIndex = 10;
testing.expectEqual(10, i1.tabIndex);
// Non-interactive elements default to -1
const d3 = document.getElementById('d3');
testing.expectEqual(-1, d3.tabIndex);
d3.tabIndex = 0;
testing.expectEqual(0, d3.tabIndex);
// Interactive elements default to 0 per spec
const input = document.createElement('input');
testing.expectEqual(0, input.tabIndex);
const button = document.createElement('button');
testing.expectEqual(0, button.tabIndex);
const a = document.createElement('a');
testing.expectEqual(0, a.tabIndex);
const select = document.createElement('select');
testing.expectEqual(0, select.tabIndex);
const textarea = document.createElement('textarea');
testing.expectEqual(0, textarea.tabIndex);
}
</script>

View File

@@ -97,3 +97,78 @@
testing.expectEqual('lazy', img.getAttribute('loading'));
}
</script>
<script id="complete">
{
// Image with no src is complete per spec
const img = document.createElement('img');
testing.expectEqual(true, img.complete);
// Image with src is also complete (headless browser, no actual fetch)
img.src = 'test.png';
testing.expectEqual(true, img.complete);
// Image constructor also complete
const img2 = new Image();
testing.expectEqual(true, img2.complete);
}
</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

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<input id="i1" placeholder="Enter name" min="0" max="100" step="5" autocomplete="email">
<input id="i2" type="file" multiple>
<input id="i3">
<script id="placeholder">
{
const i1 = document.getElementById('i1');
testing.expectEqual('Enter name', i1.placeholder);
i1.placeholder = 'Updated';
testing.expectEqual('Updated', i1.placeholder);
const i3 = document.getElementById('i3');
testing.expectEqual('', i3.placeholder);
}
</script>
<script id="min">
{
const i1 = document.getElementById('i1');
testing.expectEqual('0', i1.min);
i1.min = '10';
testing.expectEqual('10', i1.min);
const i3 = document.getElementById('i3');
testing.expectEqual('', i3.min);
}
</script>
<script id="max">
{
const i1 = document.getElementById('i1');
testing.expectEqual('100', i1.max);
i1.max = '200';
testing.expectEqual('200', i1.max);
const i3 = document.getElementById('i3');
testing.expectEqual('', i3.max);
}
</script>
<script id="step">
{
const i1 = document.getElementById('i1');
testing.expectEqual('5', i1.step);
i1.step = '0.5';
testing.expectEqual('0.5', i1.step);
const i3 = document.getElementById('i3');
testing.expectEqual('', i3.step);
}
</script>
<script id="multiple">
{
const i2 = document.getElementById('i2');
testing.expectEqual(true, i2.multiple);
i2.multiple = false;
testing.expectEqual(false, i2.multiple);
const i3 = document.getElementById('i3');
testing.expectEqual(false, i3.multiple);
i3.multiple = true;
testing.expectEqual(true, i3.multiple);
}
</script>
<script id="autocomplete">
{
const i1 = document.getElementById('i1');
testing.expectEqual('email', i1.autocomplete);
i1.autocomplete = 'off';
testing.expectEqual('off', i1.autocomplete);
const i3 = document.getElementById('i3');
testing.expectEqual('', i3.autocomplete);
}
</script>

View File

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

View File

@@ -0,0 +1,283 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<!-- Checkbox click tests -->
<input id="checkbox1" type="checkbox">
<input id="checkbox2" type="checkbox" checked>
<input id="checkbox_disabled" type="checkbox" disabled>
<!-- Radio click tests -->
<input id="radio1" type="radio" name="clickgroup" checked>
<input id="radio2" type="radio" name="clickgroup">
<input id="radio3" type="radio" name="clickgroup">
<input id="radio_disabled" type="radio" name="clickgroup" disabled>
<script id="checkbox_click_toggles">
{
const cb = $('#checkbox1');
testing.expectEqual(false, cb.checked);
cb.click();
testing.expectEqual(true, cb.checked);
cb.click();
testing.expectEqual(false, cb.checked);
}
</script>
<script id="checkbox_click_preventDefault_reverts">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
testing.expectEqual(false, cb.checked);
cb.addEventListener('click', (e) => {
testing.expectEqual(true, cb.checked, 'checkbox should be checked during click handler');
e.preventDefault();
});
cb.click();
testing.expectEqual(false, cb.checked, 'checkbox should revert after preventDefault');
}
</script>
<script id="checkbox_click_events_order">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
document.body.appendChild(cb);
const events = [];
cb.addEventListener('click', () => events.push('click'));
cb.addEventListener('input', () => events.push('input'));
cb.addEventListener('change', () => events.push('change'));
cb.click();
testing.expectEqual(3, events.length);
testing.expectEqual('click', events[0]);
testing.expectEqual('input', events[1]);
testing.expectEqual('change', events[2]);
document.body.removeChild(cb);
}
</script>
<script id="checkbox_click_preventDefault_no_input_change">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
document.body.appendChild(cb);
const events = [];
cb.addEventListener('click', (e) => {
events.push('click');
e.preventDefault();
});
cb.addEventListener('input', () => events.push('input'));
cb.addEventListener('change', () => events.push('change'));
cb.click();
testing.expectEqual(1, events.length, 'only click event should fire');
testing.expectEqual('click', events[0]);
document.body.removeChild(cb);
}
</script>
<script id="checkbox_click_state_visible_in_handler">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = true;
cb.addEventListener('click', (e) => {
testing.expectEqual(false, cb.checked, 'should see toggled state in handler');
e.preventDefault();
testing.expectEqual(false, cb.checked, 'should still be toggled after preventDefault in handler');
});
cb.click();
testing.expectEqual(true, cb.checked, 'should revert to original state after handler completes');
}
</script>
<script id="radio_click_checks_clicked">
{
const r1 = $('#radio1');
const r2 = $('#radio2');
testing.expectEqual(true, r1.checked);
testing.expectEqual(false, r2.checked);
r2.click();
testing.expectEqual(false, r1.checked);
testing.expectEqual(true, r2.checked);
}
</script>
<script id="radio_click_preventDefault_reverts">
{
const r1 = document.createElement('input');
r1.type = 'radio';
r1.name = 'testgroup';
r1.checked = true;
const r2 = document.createElement('input');
r2.type = 'radio';
r2.name = 'testgroup';
document.body.appendChild(r1);
document.body.appendChild(r2);
r2.addEventListener('click', (e) => {
testing.expectEqual(false, r1.checked, 'r1 should be unchecked during click handler');
testing.expectEqual(true, r2.checked, 'r2 should be checked during click handler');
e.preventDefault();
});
r2.click();
testing.expectEqual(true, r1.checked, 'r1 should be restored after preventDefault');
testing.expectEqual(false, r2.checked, 'r2 should revert after preventDefault');
document.body.removeChild(r1);
document.body.removeChild(r2);
}
</script>
<script id="radio_click_events_order">
{
const r = document.createElement('input');
r.type = 'radio';
r.name = 'eventtest';
document.body.appendChild(r);
const events = [];
r.addEventListener('click', () => events.push('click'));
r.addEventListener('input', () => events.push('input'));
r.addEventListener('change', () => events.push('change'));
r.click();
testing.expectEqual(3, events.length);
testing.expectEqual('click', events[0]);
testing.expectEqual('input', events[1]);
testing.expectEqual('change', events[2]);
document.body.removeChild(r);
}
</script>
<script id="radio_click_already_checked_no_events">
{
const r = document.createElement('input');
r.type = 'radio';
r.name = 'alreadytest';
r.checked = true;
document.body.appendChild(r);
const events = [];
r.addEventListener('click', () => events.push('click'));
r.addEventListener('input', () => events.push('input'));
r.addEventListener('change', () => events.push('change'));
r.click();
testing.expectEqual(1, events.length, 'only click event should fire for already-checked radio');
testing.expectEqual('click', events[0]);
document.body.removeChild(r);
}
</script>
<script id="disabled_checkbox_no_click">
{
const cb = $('#checkbox_disabled');
const events = [];
cb.addEventListener('click', () => events.push('click'));
cb.addEventListener('input', () => events.push('input'));
cb.addEventListener('change', () => events.push('change'));
cb.click();
testing.expectEqual(0, events.length, 'disabled checkbox should not fire any events');
}
</script>
<script id="disabled_radio_no_click">
{
const r = $('#radio_disabled');
const events = [];
r.addEventListener('click', () => events.push('click'));
r.addEventListener('input', () => events.push('input'));
r.addEventListener('change', () => events.push('change'));
r.click();
testing.expectEqual(0, events.length, 'disabled radio should not fire any events');
}
</script>
<script id="input_and_change_are_trusted">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
document.body.appendChild(cb);
let inputEvent = null;
let changeEvent = null;
cb.addEventListener('input', (e) => inputEvent = e);
cb.addEventListener('change', (e) => changeEvent = e);
cb.click();
testing.expectEqual(true, inputEvent.isTrusted, 'input event should be trusted');
testing.expectEqual(true, inputEvent.bubbles, 'input event should bubble');
testing.expectEqual(false, inputEvent.cancelable, 'input event should not be cancelable');
testing.expectEqual(true, changeEvent.isTrusted, 'change event should be trusted');
testing.expectEqual(true, changeEvent.bubbles, 'change event should bubble');
testing.expectEqual(false, changeEvent.cancelable, 'change event should not be cancelable');
document.body.removeChild(cb);
}
</script>
<script id="multiple_radios_click_sequence">
{
const r1 = $('#radio1');
const r2 = $('#radio2');
const r3 = $('#radio3');
// Reset to known state
r1.checked = true;
testing.expectEqual(true, r1.checked);
testing.expectEqual(false, r2.checked);
testing.expectEqual(false, r3.checked);
r2.click();
testing.expectEqual(false, r1.checked);
testing.expectEqual(true, r2.checked);
testing.expectEqual(false, r3.checked);
r3.click();
testing.expectEqual(false, r1.checked);
testing.expectEqual(false, r2.checked);
testing.expectEqual(true, r3.checked);
r1.click();
testing.expectEqual(true, r1.checked);
testing.expectEqual(false, r2.checked);
testing.expectEqual(false, r3.checked);
}
</script>

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<label id="l1" for="input1">Name</label>
<input id="input1">
<script id="htmlFor">
{
const l1 = document.getElementById('l1');
testing.expectEqual('input1', l1.htmlFor);
l1.htmlFor = 'input2';
testing.expectEqual('input2', l1.htmlFor);
const l2 = document.createElement('label');
testing.expectEqual('', l2.htmlFor);
}
</script>

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<ol>
<li id="li1" value="5">Item</li>
<li id="li2">Item</li>
</ol>
<script id="value">
{
const li1 = document.getElementById('li1');
testing.expectEqual(5, li1.value);
li1.value = 10;
testing.expectEqual(10, li1.value);
const li2 = document.getElementById('li2');
testing.expectEqual(0, li2.value);
li2.value = -3;
testing.expectEqual(-3, li2.value);
}
</script>

View File

@@ -9,4 +9,13 @@
l2.href = '/over/9000';
testing.expectEqual('http://127.0.0.1:9582/over/9000', l2.href);
l2.crossOrigin = 'nope';
testing.expectEqual('anonymous', l2.crossOrigin);
l2.crossOrigin = 'use-Credentials';
testing.expectEqual('use-credentials', l2.crossOrigin);
l2.crossOrigin = '';
testing.expectEqual('anonymous', l2.crossOrigin);
</script>

View File

@@ -50,6 +50,50 @@
}
</script>
<script id="play_pause_events">
{
const audio = document.createElement('audio');
const events = [];
audio.addEventListener('play', () => events.push('play'));
audio.addEventListener('playing', () => events.push('playing'));
audio.addEventListener('pause', () => events.push('pause'));
audio.addEventListener('emptied', () => events.push('emptied'));
// First play: paused -> playing, fires play + playing
audio.play();
testing.expectEqual('play,playing', events.join(','));
// Second play: already playing, no events
audio.play();
testing.expectEqual('play,playing', events.join(','));
// Pause: playing -> paused, fires pause
audio.pause();
testing.expectEqual('play,playing,pause', events.join(','));
// Second pause: already paused, no event
audio.pause();
testing.expectEqual('play,playing,pause', events.join(','));
// Third play: resume from pause, fires play + playing (verified in Chrome)
audio.play();
testing.expectEqual('play,playing,pause,play,playing', events.join(','));
// Pause again
audio.pause();
testing.expectEqual('play,playing,pause,play,playing,pause', events.join(','));
// Load: resets state, fires emptied
audio.load();
testing.expectEqual('play,playing,pause,play,playing,pause,emptied', events.join(','));
// Play after load: fires play + playing
audio.play();
testing.expectEqual('play,playing,pause,play,playing,pause,emptied,play,playing', events.join(','));
}
</script>
<script id="volume_muted">
{
const audio = document.getElementById('audio1');

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<ol id="ol1" start="5" reversed type="a">
<li>Item</li>
</ol>
<ol id="ol2">
<li>Item</li>
</ol>
<script id="start">
{
const ol1 = document.getElementById('ol1');
testing.expectEqual(5, ol1.start);
ol1.start = 10;
testing.expectEqual(10, ol1.start);
const ol2 = document.getElementById('ol2');
testing.expectEqual(1, ol2.start);
}
</script>
<script id="reversed">
{
const ol1 = document.getElementById('ol1');
testing.expectEqual(true, ol1.reversed);
ol1.reversed = false;
testing.expectEqual(false, ol1.reversed);
const ol2 = document.getElementById('ol2');
testing.expectEqual(false, ol2.reversed);
ol2.reversed = true;
testing.expectEqual(true, ol2.reversed);
}
</script>
<script id="type">
{
const ol1 = document.getElementById('ol1');
testing.expectEqual('a', ol1.type);
ol1.type = '1';
testing.expectEqual('1', ol1.type);
const ol2 = document.getElementById('ol2');
testing.expectEqual('1', ol2.type);
}
</script>

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<select>
<optgroup id="og1" label="Group 1" disabled>
<option>A</option>
</optgroup>
<optgroup id="og2" label="Group 2">
<option>B</option>
</optgroup>
</select>
<script id="disabled">
{
const og1 = document.getElementById('og1');
testing.expectEqual(true, og1.disabled);
og1.disabled = false;
testing.expectEqual(false, og1.disabled);
const og2 = document.getElementById('og2');
testing.expectEqual(false, og2.disabled);
og2.disabled = true;
testing.expectEqual(true, og2.disabled);
}
</script>
<script id="label">
{
const og1 = document.getElementById('og1');
testing.expectEqual('Group 1', og1.label);
og1.label = 'Updated';
testing.expectEqual('Updated', og1.label);
const og = document.createElement('optgroup');
testing.expectEqual('', og.label);
}
</script>

View File

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

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<blockquote id="q1" cite="https://example.com/source">Quote</blockquote>
<script id="cite">
{
const q1 = document.getElementById('q1');
testing.expectEqual('https://example.com/source', q1.cite);
q1.cite = 'https://example.com/other';
testing.expectEqual('https://example.com/other', q1.cite);
const q2 = document.createElement('blockquote');
testing.expectEqual('', q2.cite);
}
</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

@@ -3,7 +3,46 @@
<script id="sheet">
{
// Disconnected style element should have no sheet
testing.expectEqual(null, document.createElement('style').sheet);
// Connected style element should have a CSSStyleSheet
const style = document.createElement('style');
document.head.appendChild(style);
testing.expectEqual(true, style.sheet instanceof CSSStyleSheet);
// Same sheet instance on repeated access
testing.expectEqual(true, style.sheet === style.sheet);
// Non-CSS type should have no sheet
const lessStyle = document.createElement('style');
lessStyle.type = 'text/less';
document.head.appendChild(lessStyle);
testing.expectEqual(null, lessStyle.sheet);
// Empty type attribute is valid (defaults to text/css per spec)
const emptyType = document.createElement('style');
emptyType.setAttribute('type', '');
document.head.appendChild(emptyType);
testing.expectEqual(true, emptyType.sheet instanceof CSSStyleSheet);
// Case-insensitive type check
const upperType = document.createElement('style');
upperType.type = 'TEXT/CSS';
document.head.appendChild(upperType);
testing.expectEqual(true, upperType.sheet instanceof CSSStyleSheet);
// Disconnection clears sheet
const tempStyle = document.createElement('style');
document.head.appendChild(tempStyle);
testing.expectEqual(true, tempStyle.sheet instanceof CSSStyleSheet);
document.head.removeChild(tempStyle);
testing.expectEqual(null, tempStyle.sheet);
// ownerNode points back to the style element
const ownStyle = document.createElement('style');
document.head.appendChild(ownStyle);
testing.expectEqual(true, ownStyle.sheet.ownerNode === ownStyle);
}
</script>

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<table>
<tr>
<td id="td1" colspan="3" rowspan="2">Cell</td>
<td id="td2">Cell</td>
</tr>
</table>
<script id="colSpan">
{
const td1 = document.getElementById('td1');
testing.expectEqual(3, td1.colSpan);
td1.colSpan = 5;
testing.expectEqual(5, td1.colSpan);
const td2 = document.getElementById('td2');
testing.expectEqual(1, td2.colSpan);
// colSpan 0 clamps to 1
td2.colSpan = 0;
testing.expectEqual(1, td2.colSpan);
// colSpan > 1000 clamps to 1000
td2.colSpan = 9999;
testing.expectEqual(1000, td2.colSpan);
}
</script>
<script id="rowSpan">
{
const td1 = document.getElementById('td1');
testing.expectEqual(2, td1.rowSpan);
td1.rowSpan = 4;
testing.expectEqual(4, td1.rowSpan);
const td2 = document.getElementById('td2');
testing.expectEqual(1, td2.rowSpan);
// rowSpan 0 is valid per spec (span remaining rows)
td2.rowSpan = 0;
testing.expectEqual(0, td2.rowSpan);
// rowSpan > 65534 clamps to 65534
td2.rowSpan = 99999;
testing.expectEqual(65534, td2.rowSpan);
}
</script>

View File

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

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<time id="t1" datetime="2024-01-15">January 15</time>
<script id="dateTime">
{
const t = document.getElementById('t1');
testing.expectEqual('2024-01-15', t.dateTime);
t.dateTime = '2024-12-25T10:00';
testing.expectEqual('2024-12-25T10:00', t.dateTime);
const t2 = document.createElement('time');
testing.expectEqual('', t2.dateTime);
}
</script>

View File

@@ -0,0 +1,116 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id="test1">Test Element</div>
<div id="test2">Another Element</div>
<script id="clientDimensions">
{
const test1 = $('#test1');
// clientWidth/Height - default is 5px in dummy layout
testing.expectEqual('number', typeof test1.clientWidth);
testing.expectEqual('number', typeof test1.clientHeight);
testing.expectTrue(test1.clientWidth >= 0);
testing.expectTrue(test1.clientHeight >= 0);
// clientTop/Left should be 0 (no borders in dummy layout)
testing.expectEqual(0, test1.clientTop);
testing.expectEqual(0, test1.clientLeft);
}
</script>
<script id="scrollDimensions">
{
const test1 = $('#test1');
// In dummy layout, scroll dimensions equal client dimensions (no overflow)
testing.expectEqual(test1.clientWidth, test1.scrollWidth);
testing.expectEqual(test1.clientHeight, test1.scrollHeight);
}
</script>
<script id="scrollPosition">
{
const test1 = $('#test1');
// Initial scroll position should be 0
testing.expectEqual(0, test1.scrollTop);
testing.expectEqual(0, test1.scrollLeft);
// Setting scroll position
test1.scrollTop = 50;
testing.expectEqual(50, test1.scrollTop);
test1.scrollLeft = 25;
testing.expectEqual(25, test1.scrollLeft);
// Negative values should be clamped to 0
test1.scrollTop = -10;
testing.expectEqual(0, test1.scrollTop);
test1.scrollLeft = -5;
testing.expectEqual(0, test1.scrollLeft);
// Each element has independent scroll position
const test2 = $('#test2');
testing.expectEqual(0, test2.scrollTop);
testing.expectEqual(0, test2.scrollLeft);
test2.scrollTop = 100;
testing.expectEqual(100, test2.scrollTop);
testing.expectEqual(0, test1.scrollTop); // test1 should still be 0
}
</script>
<script id="offsetDimensions">
{
const test1 = $('#test1');
// offsetWidth/Height should be numbers
testing.expectEqual('number', typeof test1.offsetWidth);
testing.expectEqual('number', typeof test1.offsetHeight);
testing.expectTrue(test1.offsetWidth >= 0);
testing.expectTrue(test1.offsetHeight >= 0);
// Should equal client dimensions
testing.expectEqual(test1.clientWidth, test1.offsetWidth);
testing.expectEqual(test1.clientHeight, test1.offsetHeight);
}
</script>
<script id="offsetPosition">
{
const test1 = $('#test1');
const test2 = $('#test2');
// offsetTop/Left should be calculated from tree position
// These values are based on the heuristic layout engine
const top1 = test1.offsetTop;
const left1 = test1.offsetLeft;
const top2 = test2.offsetTop;
const left2 = test2.offsetLeft;
// Position values should be numbers
testing.expectEqual('number', typeof top1);
testing.expectEqual('number', typeof left1);
testing.expectEqual('number', typeof top2);
testing.expectEqual('number', typeof left2);
// Siblings should have different positions (either different x or y)
testing.expectTrue(top1 !== top2 || left1 !== left2);
}
</script>
<script id="offsetVsBounding">
{
const test1 = $('#test1');
// offsetTop/Left should match getBoundingClientRect
const rect = test1.getBoundingClientRect();
testing.expectEqual(rect.y, test1.offsetTop);
testing.expectEqual(rect.x, test1.offsetLeft);
testing.expectEqual(rect.width, test1.offsetWidth);
testing.expectEqual(rect.height, test1.offsetHeight);
}
</script>

View File

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

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=default>
let event = new FocusEvent('focus');
testing.expectEqual('focus', event.type);
testing.expectEqual(true, event instanceof FocusEvent);
testing.expectEqual(true, event instanceof UIEvent);
testing.expectEqual(true, event instanceof Event);
testing.expectEqual(null, event.relatedTarget);
</script>
<script id=parameters>
let div = document.createElement('div');
let focusEvent = new FocusEvent('blur', { relatedTarget: div });
testing.expectEqual(div, focusEvent.relatedTarget);
</script>
<script id=createEvent>
let evt = document.createEvent('focusevent');
testing.expectEqual(true, evt instanceof FocusEvent);
testing.expectEqual(true, evt instanceof UIEvent);
</script>

View File

@@ -10,11 +10,13 @@
testing.expectEqual(0, event.clientY);
testing.expectEqual(0, event.screenX);
testing.expectEqual(0, event.screenY);
testing.expectEqual(0, event.buttons);
</script>
<script id=parameters>
let new_event = new MouseEvent('click', { 'button': 0, 'clientX': 10, 'clientY': 20, screenX: 200, screenY: 500 });
let new_event = new MouseEvent('click', { 'button': 0, 'clientX': 10, 'clientY': 20, screenX: 200, screenY: 500, buttons: 5 });
testing.expectEqual(0, new_event.button);
testing.expectEqual(5, new_event.buttons);
testing.expectEqual(10, new_event.x);
testing.expectEqual(20, new_event.y);
testing.expectEqual(10, new_event.pageX);

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

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=createEvent>
let evt = document.createEvent('TextEvent');
testing.expectEqual(true, evt instanceof TextEvent);
testing.expectEqual(true, evt instanceof UIEvent);
testing.expectEqual('', evt.data);
</script>
<script id=initTextEvent>
let textEvent = document.createEvent('TextEvent');
textEvent.initTextEvent('textInput', true, false, window, 'test data');
testing.expectEqual('textInput', textEvent.type);
testing.expectEqual('test data', textEvent.data);
testing.expectEqual(true, textEvent.bubbles);
testing.expectEqual(false, textEvent.cancelable);
</script>

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=default>
let event = new WheelEvent('wheel');
testing.expectEqual('wheel', event.type);
testing.expectEqual(true, event instanceof WheelEvent);
testing.expectEqual(true, event instanceof MouseEvent);
testing.expectEqual(true, event instanceof UIEvent);
testing.expectEqual(0, event.deltaX);
testing.expectEqual(0, event.deltaY);
testing.expectEqual(0, event.deltaZ);
testing.expectEqual(0, event.deltaMode);
</script>
<script id=parameters>
let wheelEvent = new WheelEvent('wheel', {
deltaX: 10,
deltaY: 20,
deltaMode: WheelEvent.DOM_DELTA_LINE
});
testing.expectEqual(10, wheelEvent.deltaX);
testing.expectEqual(20, wheelEvent.deltaY);
testing.expectEqual(1, wheelEvent.deltaMode);
</script>
<script id=constants>
testing.expectEqual(0, WheelEvent.DOM_DELTA_PIXEL);
testing.expectEqual(1, WheelEvent.DOM_DELTA_LINE);
testing.expectEqual(2, WheelEvent.DOM_DELTA_PAGE);
</script>

View File

@@ -635,3 +635,130 @@
// https://github.com/lightpanda-io/browser/pull/1316
testing.expectError('TypeError', () => MessageEvent(''));
</script>
<div id=inline_parent><div id=inline_child></div></div>
<script id=inlineHandlerReceivesEvent>
// Test that inline onclick handler receives the event object
{
const inline_child = $('#inline_child');
let receivedType = null;
let receivedTarget = null;
let receivedCurrentTarget = null;
inline_child.onclick = function(e) {
// Capture values DURING handler execution
receivedType = e.type;
receivedTarget = e.target;
receivedCurrentTarget = e.currentTarget;
};
inline_child.click();
testing.expectEqual('click', receivedType);
testing.expectEqual(inline_child, receivedTarget);
testing.expectEqual(inline_child, receivedCurrentTarget);
}
</script>
<div id=inline_order_parent><div id=inline_order_child></div></div>
<script id=inlineHandlerOrder>
// Test that inline handler executes in proper order with addEventListener
{
const inline_order_child = $('#inline_order_child');
const inline_order_parent = $('#inline_order_parent');
const order = [];
// Capture listener on parent
inline_order_parent.addEventListener('click', () => order.push('parent-capture'), true);
// Inline handler on child (should execute at target phase)
inline_order_child.onclick = () => order.push('child-onclick');
// addEventListener on child (should execute at target phase, after onclick)
inline_order_child.addEventListener('click', () => order.push('child-listener'));
// Bubble listener on parent
inline_order_parent.addEventListener('click', () => order.push('parent-bubble'));
inline_order_child.click();
// Expected order: capture, then onclick, then addEventListener, then bubble
testing.expectEqual('parent-capture', order[0]);
testing.expectEqual('child-onclick', order[1]);
testing.expectEqual('child-listener', order[2]);
testing.expectEqual('parent-bubble', order[3]);
testing.expectEqual(4, order.length);
}
</script>
<div id=inline_prevent><div id=inline_prevent_child></div></div>
<script id=inlineHandlerPreventDefault>
// Test that inline handler can preventDefault and it affects addEventListener listeners
{
const inline_prevent_child = $('#inline_prevent_child');
let preventDefaultCalled = false;
let listenerSawPrevented = false;
inline_prevent_child.onclick = function(e) {
e.preventDefault();
preventDefaultCalled = true;
};
inline_prevent_child.addEventListener('click', (e) => {
listenerSawPrevented = e.defaultPrevented;
});
const result = inline_prevent_child.dispatchEvent(new MouseEvent('click', {
bubbles: true,
cancelable: true
}));
testing.expectEqual(true, preventDefaultCalled);
testing.expectEqual(true, listenerSawPrevented);
testing.expectEqual(false, result); // dispatchEvent returns false when prevented
}
</script>
<div id=inline_stop_parent><div id=inline_stop_child></div></div>
<script id=inlineHandlerStopPropagation>
// Test that inline handler can stopPropagation
{
const inline_stop_child = $('#inline_stop_child');
const inline_stop_parent = $('#inline_stop_parent');
let childCalled = false;
let parentCalled = false;
inline_stop_child.onclick = function(e) {
childCalled = true;
e.stopPropagation();
};
inline_stop_parent.addEventListener('click', () => {
parentCalled = true;
});
inline_stop_child.click();
testing.expectEqual(true, childCalled);
testing.expectEqual(false, parentCalled); // Should not bubble to parent
}
</script>
<div id=inline_replace_test></div>
<script id=inlineHandlerReplacement>
// Test that setting onclick property replaces previous handler
{
const inline_replace_test = $('#inline_replace_test');
let calls = [];
inline_replace_test.onclick = () => calls.push('first');
inline_replace_test.click();
inline_replace_test.onclick = () => calls.push('second');
inline_replace_test.click();
testing.expectEqual('first', calls[0]);
testing.expectEqual('second', calls[1]);
testing.expectEqual(2, calls.length);
}
</script>

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<script src="testing.js"></script>
<script id=constructor-basic>
{
const img = new ImageData(10, 20);
testing.expectEqual(10, img.width);
testing.expectEqual(20, img.height);
testing.expectEqual("srgb", img.colorSpace);
testing.expectEqual("rgba-unorm8", img.pixelFormat);
}
</script>
<script id=data-property>
{
const img = new ImageData(2, 3);
const data = img.data;
testing.expectEqual(true, data instanceof Uint8ClampedArray);
// 2 * 3 * 4 (RGBA) = 24 bytes
testing.expectEqual(24, data.length);
}
</script>
<script id=data-initialized-to-zero>
{
const img = new ImageData(2, 2);
const data = img.data;
for (let i = 0; i < data.length; i++) {
testing.expectEqual(0, data[i]);
}
}
</script>
<script id=data-mutability>
{
const img = new ImageData(1, 1);
const data = img.data;
// Set pixel to red (RGBA)
data[0] = 255;
data[1] = 0;
data[2] = 0;
data[3] = 255;
// Read back through the same accessor
const data2 = img.data;
testing.expectEqual(255, data2[0]);
testing.expectEqual(0, data2[1]);
testing.expectEqual(0, data2[2]);
testing.expectEqual(255, data2[3]);
}
</script>
<script id=constructor-with-settings>
{
const img = new ImageData(5, 5, { colorSpace: "srgb" });
testing.expectEqual(5, img.width);
testing.expectEqual(5, img.height);
testing.expectEqual("srgb", img.colorSpace);
}
</script>
<script id=constructor-invalid-colorspace>
testing.expectError("TypeError", () => {
new ImageData(5, 5, { colorSpace: "display-p3" });
});
</script>
<script id=single-pixel>
{
const img = new ImageData(1, 1);
testing.expectEqual(4, img.data.length);
testing.expectEqual(1, img.width);
testing.expectEqual(1, img.height);
}
</script>

View File

@@ -25,6 +25,8 @@
testing.expectEqual('number', typeof entry.intersectionRatio);
testing.expectEqual('object', typeof entry.boundingClientRect);
testing.expectEqual('object', typeof entry.intersectionRect);
testing.expectEqual('number', typeof entry.time);
testing.expectEqual(true, entry.time > 0);
observer.disconnect();
});

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