Compare commits

..

199 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
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
Pierre Tachoire
3e1909b645 ci: use cgroups with user's permissions 2026-02-10 18:43:31 +01:00
Nikolay Govorov
a4b1fbd6ee Use cgroups for RAM mesurement 2026-02-10 01:34:17 +00:00
Halil Durak
6d2ef9be5d change hash generation of global event handlers
Small change inspired from #1475.
2026-02-06 22:35:35 +03:00
145 changed files with 5924 additions and 1426 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.2.8'
default: 'v0.2.9'
v8:
description: 'v8 version to install'
required: false

View File

@@ -122,10 +122,19 @@ jobs:
needs: zig-build-release
env:
MAX_MEMORY: 26000
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
@@ -150,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: |
@@ -178,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

@@ -3,7 +3,7 @@ FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4
ARG ZIG_V8=v0.2.8
ARG ZIG_V8=v0.2.9
ARG TARGETPLATFORM
RUN apt-get update -yq && \

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

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

View File

@@ -29,6 +29,7 @@ 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,
@@ -54,6 +55,9 @@ pub fn deinit(self: *ArenaPool) void {
}
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;
@@ -73,6 +77,12 @@ 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();
@@ -80,8 +90,12 @@ pub fn release(self: *ArenaPool, allocator: Allocator) void {
return;
}
_ = arena.reset(.{ .retain_with_limit = self.retain_bytes });
entry.next = self.free_list;
self.free_list_len = free_list_len + 1;
self.free_list = entry;
}
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 });
}

View File

@@ -30,6 +30,13 @@ pub const RunMode = enum {
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,
@@ -145,6 +152,20 @@ pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
};
}
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,
@@ -156,16 +177,19 @@ pub const Serve = struct {
host: []const u8 = "127.0.0.1",
port: u16 = 9222,
timeout: u31 = 10,
max_connections: u16 = 16,
max_tabs_per_connection: u16 = 8,
max_memory_per_tab: u64 = 512 * 1024 * 1024,
max_pending_connections: u16 = 128,
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: bool = false,
dump_mode: ?DumpFormat = null,
common: Common = .{},
withbase: bool = false,
strip: dump.Opts.Strip = .{},
@@ -302,11 +326,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\
\\fetch command
\\Fetches the specified URL
\\Example: {s} fetch --dump https://lightpanda.io/
\\Example: {s} fetch --dump html https://lightpanda.io/
\\
\\Options:
\\--dump Dumps document to stdout.
\\ Defaults to false.
\\ 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
@@ -333,18 +358,11 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\--timeout Inactivity timeout in seconds before disconnecting clients
\\ Defaults to 10 (seconds). Limited to 604800 (1 week).
\\
\\--max_connections
\\--cdp_max_connections
\\ Maximum number of simultaneous CDP connections.
\\ Defaults to 16.
\\
\\--max_tabs Maximum number of tabs per CDP connection.
\\ Defaults to 8.
\\
\\--max_tab_memory
\\ Maximum memory per tab in bytes.
\\ Defaults to 536870912 (512 MB).
\\
\\--max_pending_connections
\\--cdp_max_pending_connections
\\ Maximum pending connections in the accept queue.
\\ Defaults to 128.
\\
@@ -479,53 +497,27 @@ fn parseServeArgs(
continue;
}
if (std.mem.eql(u8, "--max_connections", opt)) {
if (std.mem.eql(u8, "--cdp_max_connections", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--max_connections" });
log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_connections" });
return error.InvalidArgument;
};
serve.max_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--max_connections", .err = err });
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, "--max_tabs", opt)) {
if (std.mem.eql(u8, "--cdp_max_pending_connections", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--max_tabs" });
log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_pending_connections" });
return error.InvalidArgument;
};
serve.max_tabs_per_connection = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--max_tabs", .err = err });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--max_tab_memory", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--max_tab_memory" });
return error.InvalidArgument;
};
serve.max_memory_per_tab = std.fmt.parseInt(u64, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--max_tab_memory", .err = err });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--max_pending_connections", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--max_pending_connections" });
return error.InvalidArgument;
};
serve.max_pending_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--max_pending_connections", .err = err });
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;
@@ -546,7 +538,7 @@ fn parseFetchArgs(
allocator: Allocator,
args: *std.process.ArgIterator,
) !Fetch {
var fetch_dump: bool = false;
var dump_mode: ?DumpFormat = null;
var withbase: bool = false;
var url: ?[:0]const u8 = null;
var common: Common = .{};
@@ -554,7 +546,17 @@ fn parseFetchArgs(
while (args.next()) |opt| {
if (std.mem.eql(u8, "--dump", opt)) {
fetch_dump = true;
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;
}
@@ -621,7 +623,7 @@ fn parseFetchArgs(
return .{
.url = url.?,
.dump = fetch_dump,
.dump_mode = dump_mode,
.strip = strip,
.common = common,
.withbase = withbase,

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,96 +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);
},
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
@@ -217,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 };
@@ -232,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(),
@@ -258,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 {
@@ -314,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;
}
@@ -367,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
@@ -472,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);
}
@@ -707,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;
@@ -735,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;
}

View File

@@ -24,9 +24,9 @@ const ArenaAllocator = std.heap.ArenaAllocator;
const js = @import("js/js.zig");
const log = @import("../log.zig");
const App = @import("../App.zig");
const HttpClient = @import("../http/Client.zig");
const ArenaPool = App.ArenaPool;
const HttpClient = App.Http.Client;
const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -44,13 +44,10 @@ session: ?Session,
allocator: Allocator,
arena_pool: *ArenaPool,
http_client: *HttpClient,
call_arena: ArenaAllocator,
page_arena: ArenaAllocator,
session_arena: ArenaAllocator,
transfer_arena: ArenaAllocator,
const InitOpts = struct {
env: js.Env.InitOpts = .{},
http_client: *HttpClient,
};
pub fn init(app: *App, opts: InitOpts) !Browser {
@@ -65,21 +62,13 @@ pub fn init(app: *App, opts: InitOpts) !Browser {
.session = null,
.allocator = allocator,
.arena_pool = &app.arena_pool,
.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),
.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();
}
pub fn newSession(self: *Browser, notification: *Notification) !*Session {
@@ -94,7 +83,6 @@ 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.memoryPressureNotification(.critical);
}
}

View File

@@ -28,6 +28,7 @@ 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;
@@ -66,13 +67,13 @@ lookup: std.HashMapUnmanaged(
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 = .{},
};
@@ -254,20 +255,27 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
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 });
};
}
@@ -302,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;
}
@@ -329,13 +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(.{
.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;
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;
}
}
}
@@ -460,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) {
@@ -575,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

@@ -43,7 +43,9 @@ 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 {
@@ -149,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),
};
}
@@ -172,6 +175,13 @@ pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
return chain.get(1);
}
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, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
@@ -329,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, .{

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" {

View File

@@ -83,7 +83,7 @@ _session: *Session,
_event_manager: EventManager,
_parse_mode: enum { document, fragment, document_write },
_parse_mode: enum { document, fragment, document_write } = .document,
// See Attribute.List for what this is. TL;DR: proper DOM Attribute Nodes are
// fat yet rarely needed. We only create them on-demand, but still need proper
@@ -91,21 +91,22 @@ _parse_mode: enum { document, fragment, document_write },
// a look here. We don't store this in the Element or Attribute.List.Entry
// because that would require additional space per element / Attribute.List.Entry
// even thoug we'll create very few (if any) actual *Attributes.
_attribute_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attribute),
_attribute_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attribute) = .empty,
// Same as _atlribute_lookup, but instead of individual attributes, this is for
// the return of elements.attributes.
_attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attribute.NamedNodeMap),
_attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attribute.NamedNodeMap) = .empty,
// Lazily-created style, classList, and dataset objects. Only stored for elements
// that actually access these features via JavaScript, saving 24 bytes per element.
_element_styles: Element.StyleLookup = .{},
_element_datasets: Element.DatasetLookup = .{},
_element_class_lists: Element.ClassListLookup = .{},
_element_rel_lists: Element.RelListLookup = .{},
_element_shadow_roots: Element.ShadowRootLookup = .{},
_node_owner_documents: Node.OwnerDocumentLookup = .{},
_element_assigned_slots: Element.AssignedSlotLookup = .{},
_element_styles: Element.StyleLookup = .empty,
_element_datasets: Element.DatasetLookup = .empty,
_element_class_lists: Element.ClassListLookup = .empty,
_element_rel_lists: Element.RelListLookup = .empty,
_element_shadow_roots: Element.ShadowRootLookup = .empty,
_node_owner_documents: Node.OwnerDocumentLookup = .empty,
_element_assigned_slots: Element.AssignedSlotLookup = .empty,
_element_scroll_positions: Element.ScrollPositionLookup = .empty,
/// Lazily-created inline event listeners (or listeners provided as attributes).
/// Avoids bloating all elements with extra function fields for rare usage.
@@ -125,7 +126,7 @@ _element_assigned_slots: Element.AssignedSlotLookup = .{},
/// ```js
/// img.setAttribute("onload", "(() => { ... })()");
/// ```
_element_attr_listeners: GlobalEventHandlersLookup = .{},
_element_attr_listeners: GlobalEventHandlersLookup = .empty,
/// `load` events that'll be fired before window's `load` event.
/// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it.
@@ -167,9 +168,9 @@ _undefined_custom_elements: std.ArrayList(*Element.Html.Custom) = .{},
// for heap allocations and managing WebAPI objects
_factory: Factory,
_load_state: LoadState,
_load_state: LoadState = .waiting,
_parse_state: ParseState,
_parse_state: ParseState = .pre,
_notified_network_idle: IdleNotification = .init,
_notified_network_almost_idle: IdleNotification = .init,
@@ -179,19 +180,19 @@ _notified_network_almost_idle: IdleNotification = .init,
_queued_navigation: ?QueuedNavigation = null,
// The URL of the current page
url: [:0]const u8,
url: [:0]const u8 = "about:blank",
// The base url specifies the base URL used to resolve the relative urls.
// It is set by a <base> tag.
// If null the url must be used.
base_url: ?[:0]const u8,
base_url: ?[:0]const u8 = null,
// referer header cache.
referer_header: ?[:0]const u8,
referer_header: ?[:0]const u8 = null,
// Arbitrary buffer. Need to temporarily lowercase a value? Use this. No lifetime
// guarantee - it's valid until someone else uses it.
buf: [BUF_SIZE]u8,
buf: [BUF_SIZE]u8 = undefined,
// access to the JavaScript engine
js: *JS.Context,
@@ -209,13 +210,13 @@ arena_pool: *ArenaPool,
_arena_pool_leak_track: (if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct {
owner: []const u8,
count: usize,
}) else void),
}) else void) = if (IS_DEBUG) .empty else {},
window: *Window,
document: *Document,
// DOM version used to invalidate cached state of "live" collections
version: usize,
version: usize = 0,
_req_id: ?usize = null,
_navigated_options: ?NavigatedOpts = null,
@@ -224,19 +225,63 @@ pub fn init(self: *Page, session: *Session) !void {
if (comptime IS_DEBUG) {
log.debug(.page, "page.init", .{});
}
const browser = session.browser;
self._session = session;
const arena_pool = browser.arena_pool;
const page_arena = try arena_pool.acquire();
errdefer arena_pool.release(page_arena);
self.arena_pool = browser.arena_pool;
self.arena = browser.page_arena.allocator();
self.call_arena = browser.call_arena.allocator();
const call_arena = try arena_pool.acquire();
errdefer arena_pool.release(call_arena);
if (comptime IS_DEBUG) {
self._arena_pool_leak_track = .empty;
var factory = Factory.init(page_arena, self);
const document = (try factory.document(Node.Document.HTMLDocument{
._proto = undefined,
})).asDocument();
self.* = .{
.js = undefined,
.arena = page_arena,
.document = document,
.window = undefined,
.arena_pool = arena_pool,
.call_arena = call_arena,
._session = session,
._factory = factory,
._script_manager = undefined,
._event_manager = EventManager.init(page_arena, self),
};
self.window = try factory.eventTarget(Window{
._proto = undefined,
._document = self.document,
._location = &default_location,
._performance = Performance.init(),
._screen = try factory.eventTarget(Screen{
._proto = undefined,
._orientation = null,
}),
._visual_viewport = try factory.eventTarget(VisualViewport{
._proto = undefined,
}),
});
self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self);
errdefer self._script_manager.deinit();
self.js = try browser.env.createContext(self, true);
errdefer self.js.deinit();
if (comptime builtin.is_test == false) {
// HTML test runner manually calls these as necessary
try self.js.scheduler.add(session.browser, struct {
fn runMessageLoop(ctx: *anyopaque) !?u32 {
const b: *@import("Browser.zig") = @ptrCast(@alignCast(ctx));
b.runMessageLoop();
return 250;
}
}.runMessageLoop, 250, .{ .name = "page.messageLoop" });
}
try self.reset(true);
}
pub fn deinit(self: *Page) void {
@@ -265,132 +310,15 @@ pub fn deinit(self: *Page) void {
}
}
}
}
fn reset(self: *Page, comptime initializing: bool) !void {
const browser = self._session.browser;
if (comptime initializing == false) {
browser.env.destroyContext(self.js);
// We force a garbage collection between page navigations to keep v8
// memory usage as low as possible.
browser.env.memoryPressureNotification(.moderate);
self._script_manager.shutdown = true;
browser.http_client.abort();
self._script_manager.deinit();
// destroying the context, and aborting the http_client can both cause
// resources to be freed. We need to check for a leak after we've finished
// all of our cleanup.
if (comptime IS_DEBUG) {
var it = self._arena_pool_leak_track.valueIterator();
while (it.next()) |value_ptr| {
if (value_ptr.count > 0) {
log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner });
}
}
self._arena_pool_leak_track = .empty;
}
_ = browser.page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
}
self._factory = Factory.init(self);
self.version = 0;
self.url = "about:blank";
self.base_url = null;
self.referer_header = null;
self.document = (try self._factory.document(Node.Document.HTMLDocument{ ._proto = undefined })).asDocument();
const storage_bucket = try self._factory.create(storage.Bucket{});
const screen = try Screen.init(self);
const visual_viewport = try VisualViewport.init(self);
self.window = try self._factory.eventTarget(Window{
._document = self.document,
._storage_bucket = storage_bucket,
._performance = Performance.init(),
._proto = undefined,
._location = &default_location,
._screen = screen,
._visual_viewport = visual_viewport,
});
self.window._document = self.document;
self.window._location = &default_location;
self._parse_state = .pre;
self._load_state = .waiting;
self._queued_navigation = null;
self._parse_mode = .document;
self._attribute_lookup = .empty;
self._attribute_named_node_map_lookup = .empty;
self._event_manager = EventManager.init(self);
self._script_manager = ScriptManager.init(self);
errdefer self._script_manager.deinit();
self.js = try browser.env.createContext(self, true);
errdefer self.js.deinit();
self._element_styles = .{};
self._element_datasets = .{};
self._element_class_lists = .{};
self._element_rel_lists = .{};
self._element_shadow_roots = .{};
self._node_owner_documents = .{};
self._element_assigned_slots = .{};
self._element_attr_listeners = .{};
self._to_load = .{};
self._notified_network_idle = .init;
self._notified_network_almost_idle = .init;
self._performance_observers = .{};
self._mutation_observers = .{};
self._mutation_delivery_scheduled = false;
self._mutation_delivery_depth = 0;
self._intersection_observers = .{};
self._intersection_check_scheduled = false;
self._intersection_delivery_scheduled = false;
self._slots_pending_slotchange = .{};
self._slotchange_delivery_scheduled = false;
self._customized_builtin_definitions = .{};
self._customized_builtin_connected_callback_invoked = .{};
self._customized_builtin_disconnected_callback_invoked = .{};
self._undefined_custom_elements = .{};
if (comptime IS_DEBUG) {
self._arena_pool_leak_track = .{};
}
try self.registerBackgroundTasks();
self.arena_pool.release(self.call_arena);
self.arena_pool.release(self.arena);
}
pub fn base(self: *const Page) [:0]const u8 {
return self.base_url orelse self.url;
}
fn registerBackgroundTasks(self: *Page) !void {
if (comptime builtin.is_test) {
// HTML test runner manually calls these as necessary
return;
}
const Browser = @import("Browser.zig");
try self.js.scheduler.add(self._session.browser, struct {
fn runMessageLoop(ctx: *anyopaque) !?u32 {
const b: *Browser = @ptrCast(@alignCast(ctx));
b.runMessageLoop();
return 250;
}
}.runMessageLoop, 250, .{ .name = "page.messageLoop" });
}
pub fn getTitle(self: *Page) !?[]const u8 {
if (self.window._document.is(Document.HTMLDocument)) |html_doc| {
return try html_doc.getTitle(self);
@@ -461,12 +389,8 @@ pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool {
}
pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !void {
lp.assert(self._load_state == .waiting, "page.renavigate", .{});
const session = self._session;
if (self._parse_state != .pre or self._load_state != .waiting) {
// it's possible for navigate to be called multiple times on the
// same page (via CDP). We want to reset the page between each call.
try self.reset(false);
}
self._load_state = .parsing;
const req_id = self._session.browser.http_client.nextReqId();
@@ -710,18 +634,6 @@ fn _documentIsComplete(self: *Page) !void {
for (self._to_load.items) |element| {
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
defer if (!event._v8_handoff) event.deinit(false);
// Dispatch inline event.
blk: {
const html_element = element.is(HtmlElement) orelse break :blk;
const listener = (try html_element.getOnLoad(self)) orelse break :blk;
ls.toLocal(listener).call(void, .{}) catch |err| {
log.warn(.event, "inline load event", .{ .element = element, .err = err });
};
}
// Dispatch events registered to event manager.
try self._event_manager.dispatch(element.asEventTarget(), event);
}
@@ -796,7 +708,10 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void {
try arr.appendSlice(self.arena, "<html><head><meta charset=\"utf-8\"></head><body><pre>");
self._parse_state = .{ .text = arr };
},
else => self._parse_state = .{ .raw = .{} },
.image_jpeg, .image_gif, .image_png, .image_webp => {
self._parse_state = .{ .image = .empty };
},
else => self._parse_state = .{ .raw = .empty },
}
}
@@ -818,7 +733,7 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void {
v = v[index + 1 ..];
}
},
.raw => |*buf| try buf.appendSlice(self.arena, data),
.raw, .image => |*buf| try buf.appendSlice(self.arena, data),
.pre => unreachable,
.complete => unreachable,
.err => unreachable,
@@ -841,12 +756,13 @@ fn pageDoneCallback(ctx: *anyopaque) !void {
log.debug(.page, "page.load.complete", .{ .url = self.url });
};
const parse_arena = try self.getArena(.{ .debug = "Page.parse" });
defer self.releaseArena(parse_arena);
var parser = Parser.init(parse_arena, self.document.asNode(), self);
switch (self._parse_state) {
.html => |buf| {
const parse_arena = try self.getArena(.{ .debug = "Page.parse" });
defer self.releaseArena(parse_arena);
var parser = Parser.init(parse_arena, self.document.asNode(), self);
parser.parse(buf.items);
self._script_manager.staticScriptsDone();
if (self._script_manager.isDone()) {
@@ -858,16 +774,26 @@ fn pageDoneCallback(ctx: *anyopaque) !void {
},
.text => |*buf| {
try buf.appendSlice(self.arena, "</pre></body></html>");
const parse_arena = try self.getArena(.{ .debug = "Page.parse" });
defer self.releaseArena(parse_arena);
var parser = Parser.init(parse_arena, self.document.asNode(), self);
parser.parse(buf.items);
self.documentIsComplete();
},
.image => |buf| {
self._parse_state = .{ .raw_done = buf.items };
// Use empty an HTML containing the image.
const html = try std.mem.concat(parse_arena, u8, &.{
"<html><head><meta charset=\"utf-8\"></head><body><img src=\"",
self.url,
"\"></body></htm>",
});
parser.parse(html);
self.documentIsComplete();
},
.raw => |buf| {
self._parse_state = .{ .raw_done = buf.items };
// Use empty an empty HTML document.
parser.parse("<html><head><meta charset=\"utf-8\"></head><body></body></htm>");
self.documentIsComplete();
},
.pre => {
@@ -875,6 +801,20 @@ fn pageDoneCallback(ctx: *anyopaque) !void {
// We assume we have received an OK status (checked in Client.headerCallback)
// so we load a blank document to navigate away from any prior page.
self._parse_state = .{ .complete = {} };
// Use empty an empty HTML document.
parser.parse("<html><head><meta charset=\"utf-8\"></head><body></body></htm>");
self.documentIsComplete();
},
.err => |err| {
// Generate a pseudo HTML page indicating the failure.
const html = try std.mem.concat(parse_arena, u8, &.{
"<html><head><meta charset=\"utf-8\"></head><body><h1>Navigation failed</h1><p>Reason: ",
@errorName(err),
"</p></body></htm>",
});
parser.parse(html);
self.documentIsComplete();
},
else => unreachable,
@@ -885,8 +825,14 @@ fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void {
log.err(.page, "navigate failed", .{ .err = err });
var self: *Page = @ptrCast(@alignCast(ctx));
self.clearTransferArena();
self._parse_state = .{ .err = err };
// In case of error, we want to complete the page with a custom HTML
// containing the error.
pageDoneCallback(ctx) catch |e| {
log.err(.browser, "pageErrorCallback", .{ .err = e });
return;
};
}
// The transfer arena is useful and interesting, but has a weird lifetime.
@@ -903,7 +849,7 @@ fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void {
// why would we want to) and requires the body to live until the transfer
// is complete.
fn clearTransferArena(self: *Page) void {
_ = self._session.browser.transfer_arena.reset(.{ .retain_with_limit = 4 * 1024 });
self.arena_pool.reset(self._session.transfer_arena, 4 * 1024);
}
pub fn wait(self: *Page, wait_ms: u32) Session.WaitResult {
@@ -947,7 +893,7 @@ fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult {
while (true) {
switch (self._parse_state) {
.pre, .raw, .text => {
.pre, .raw, .text, .image => {
// The main page hasn't started/finished navigating.
// There's no JS to run, and no reason to run the scheduler.
if (http_client.active == 0 and exit_when_done) {
@@ -1254,8 +1200,10 @@ pub fn setAttrListener(
});
}
const key = global_event_handlers.calculateKey(element.asEventTarget(), listener_type);
const gop = try self._element_attr_listeners.getOrPut(self.arena, key);
const gop = try self._element_attr_listeners.getOrPut(self.arena, .{
.target = element.asEventTarget(),
.handler = listener_type,
});
gop.value_ptr.* = listener_callback;
}
@@ -1265,8 +1213,10 @@ pub fn getAttrListener(
element: *Element,
listener_type: GlobalEventHandler,
) ?JS.Function.Global {
const key = global_event_handlers.calculateKey(element.asEventTarget(), listener_type);
return self._element_attr_listeners.get(key);
return self._element_attr_listeners.get(.{
.target = element.asEventTarget(),
.handler = listener_type,
});
}
pub fn registerPerformanceObserver(self: *Page, observer: *PerformanceObserver) !void {
@@ -1293,6 +1243,11 @@ pub fn notifyPerformanceObservers(self: *Page, entry: *Performance.Entry) !void
}
}
try self.schedulePerformanceObserverDelivery();
}
/// Schedules async delivery of performance observer records.
pub fn schedulePerformanceObserverDelivery(self: *Page) !void {
// Already scheduled.
if (self._performance_delivery_scheduled) {
return;
@@ -1659,10 +1614,10 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const
.{ ._proto = undefined, ._tag_name = String.init(undefined, "dd", .{}) catch unreachable, ._tag = .dd },
),
asUint("dl") => return self.createHtmlElementT(
Element.Html.Generic,
Element.Html.DList,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "dl", .{}) catch unreachable, ._tag = .dl },
.{ ._proto = undefined },
),
asUint("dt") => return self.createHtmlElementT(
Element.Html.Generic,
@@ -2911,6 +2866,7 @@ const ParseState = union(enum) {
err: anyerror,
html: std.ArrayList(u8),
text: std.ArrayList(u8),
image: std.ArrayList(u8),
raw: std.ArrayList(u8),
raw_done: []const u8,
};
@@ -3089,21 +3045,25 @@ pub fn handleClick(self: *Page, target: *Node) !void {
return;
}
try element.focus(self);
try self.scheduleNavigation(href, .{
.reason = .script,
.kind = .{ .push = null },
}, .anchor);
},
.input => |input| switch (input._input_type) {
.submit => return self.submitForm(element, input.getForm(self), .{}),
else => self.window._document._active_element = element,
.input => |input| {
try element.focus(self);
if (input._input_type == .submit) {
return self.submitForm(element, input.getForm(self), .{});
}
},
.button => |button| {
try element.focus(self);
if (std.mem.eql(u8, button.getType(), "submit")) {
return self.submitForm(element, button.getForm(self), .{});
}
},
.select, .textarea => self.window._document._active_element = element,
.select, .textarea => try element.focus(self),
else => {},
}
}

View File

@@ -16,12 +16,54 @@
// 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 builtin = @import("builtin");
const std = @import("std");
const log = @import("../log.zig");
pub const CompiledPattern = struct {
pattern: []const u8,
ty: enum {
prefix, // "/admin/" - prefix match
exact, // "/admin$" - exact match
wildcard, // any pattern that contains *
},
fn compile(pattern: []const u8) CompiledPattern {
if (pattern.len == 0) {
return .{
.pattern = pattern,
.ty = .prefix,
};
}
const is_wildcard = std.mem.indexOfScalar(u8, pattern, '*') != null;
if (is_wildcard) {
return .{
.pattern = pattern,
.ty = .wildcard,
};
}
const has_end_anchor = pattern[pattern.len - 1] == '$';
return .{
.pattern = pattern,
.ty = if (has_end_anchor) .exact else .prefix,
};
}
};
pub const Rule = union(enum) {
allow: []const u8,
disallow: []const u8,
allow: CompiledPattern,
disallow: CompiledPattern,
fn allowRule(pattern: []const u8) Rule {
return .{ .allow = CompiledPattern.compile(pattern) };
}
fn disallowRule(pattern: []const u8) Rule {
return .{ .disallow = CompiledPattern.compile(pattern) };
}
};
pub const Key = enum {
@@ -44,11 +86,22 @@ pub const RobotStore = struct {
const Context = @This();
pub fn hash(_: Context, value: []const u8) u32 {
var hasher = std.hash.Wyhash.init(value.len);
for (value) |c| {
std.hash.autoHash(&hasher, std.ascii.toLower(c));
var key = value;
var buf: [128]u8 = undefined;
var h = std.hash.Wyhash.init(value.len);
while (key.len >= 128) {
const lower = std.ascii.lowerString(buf[0..], key[0..128]);
h.update(lower);
key = key[128..];
}
return @truncate(hasher.final());
if (key.len > 0) {
const lower = std.ascii.lowerString(buf[0..key.len], key);
h.update(lower);
}
return @truncate(h.final());
}
pub fn eql(_: Context, a: []const u8, b: []const u8) bool {
@@ -58,12 +111,16 @@ pub const RobotStore = struct {
allocator: std.mem.Allocator,
map: RobotsMap,
mutex: std.Thread.Mutex = .{},
pub fn init(allocator: std.mem.Allocator) RobotStore {
return .{ .allocator = allocator, .map = .empty };
}
pub fn deinit(self: *RobotStore) void {
self.mutex.lock();
defer self.mutex.unlock();
var iter = self.map.iterator();
while (iter.next()) |entry| {
@@ -79,6 +136,9 @@ pub const RobotStore = struct {
}
pub fn get(self: *RobotStore, url: []const u8) ?RobotsEntry {
self.mutex.lock();
defer self.mutex.unlock();
return self.map.get(url);
}
@@ -87,11 +147,17 @@ pub const RobotStore = struct {
}
pub fn put(self: *RobotStore, url: []const u8, robots: Robots) !void {
self.mutex.lock();
defer self.mutex.unlock();
const duped = try self.allocator.dupe(u8, url);
try self.map.put(self.allocator, duped, .{ .present = robots });
}
pub fn putAbsent(self: *RobotStore, url: []const u8) !void {
self.mutex.lock();
defer self.mutex.unlock();
const duped = try self.allocator.dupe(u8, url);
try self.map.put(self.allocator, duped, .absent);
}
@@ -112,8 +178,8 @@ const State = struct {
fn freeRulesInList(allocator: std.mem.Allocator, rules: []const Rule) void {
for (rules) |rule| {
switch (rule) {
.allow => |value| allocator.free(value),
.disallow => |value| allocator.free(value),
.allow => |compiled| allocator.free(compiled.pattern),
.disallow => |compiled| allocator.free(compiled.pattern),
}
}
}
@@ -122,7 +188,7 @@ fn parseRulesWithUserAgent(
allocator: std.mem.Allocator,
user_agent: []const u8,
raw_bytes: []const u8,
) ![]const Rule {
) ![]Rule {
var rules: std.ArrayList(Rule) = .empty;
defer rules.deinit(allocator);
@@ -201,13 +267,13 @@ fn parseRulesWithUserAgent(
.in_our_entry => {
const duped_value = try allocator.dupe(u8, value);
errdefer allocator.free(duped_value);
try rules.append(allocator, .{ .allow = duped_value });
try rules.append(allocator, Rule.allowRule(duped_value));
},
.in_other_entry => {},
.in_wildcard_entry => {
const duped_value = try allocator.dupe(u8, value);
errdefer allocator.free(duped_value);
try wildcard_rules.append(allocator, .{ .allow = duped_value });
try wildcard_rules.append(allocator, Rule.allowRule(duped_value));
},
.not_in_entry => {
log.warn(.browser, "robots unexpected rule", .{ .rule = "allow" });
@@ -220,15 +286,19 @@ fn parseRulesWithUserAgent(
switch (state.entry) {
.in_our_entry => {
if (value.len == 0) continue;
const duped_value = try allocator.dupe(u8, value);
errdefer allocator.free(duped_value);
try rules.append(allocator, .{ .disallow = duped_value });
try rules.append(allocator, Rule.disallowRule(duped_value));
},
.in_other_entry => {},
.in_wildcard_entry => {
if (value.len == 0) continue;
const duped_value = try allocator.dupe(u8, value);
errdefer allocator.free(duped_value);
try wildcard_rules.append(allocator, .{ .disallow = duped_value });
try wildcard_rules.append(allocator, Rule.disallowRule(duped_value));
},
.not_in_entry => {
log.warn(.browser, "robots unexpected rule", .{ .rule = "disallow" });
@@ -252,6 +322,39 @@ fn parseRulesWithUserAgent(
pub fn fromBytes(allocator: std.mem.Allocator, user_agent: []const u8, bytes: []const u8) !Robots {
const rules = try parseRulesWithUserAgent(allocator, user_agent, bytes);
// sort by order once.
std.mem.sort(Rule, rules, {}, struct {
fn lessThan(_: void, a: Rule, b: Rule) bool {
const a_len = switch (a) {
.allow => |p| p.pattern.len,
.disallow => |p| p.pattern.len,
};
const b_len = switch (b) {
.allow => |p| p.pattern.len,
.disallow => |p| p.pattern.len,
};
// Sort by length first.
if (a_len != b_len) {
return a_len > b_len;
}
// Otherwise, allow should beat disallow.
const a_is_allow = switch (a) {
.allow => true,
.disallow => false,
};
const b_is_allow = switch (b) {
.allow => true,
.disallow => false,
};
return a_is_allow and !b_is_allow;
}
}.lessThan);
return .{ .rules = rules };
}
@@ -260,86 +363,102 @@ pub fn deinit(self: *Robots, allocator: std.mem.Allocator) void {
allocator.free(self.rules);
}
fn matchPatternRecursive(pattern: []const u8, path: []const u8, exact_match: bool) bool {
if (pattern.len == 0) return true;
const star_pos = std.mem.indexOfScalar(u8, pattern, '*') orelse {
if (exact_match) {
// If we end in '$', we must be exactly equal.
return std.mem.eql(u8, path, pattern);
} else {
// Otherwise, we are just a prefix.
return std.mem.startsWith(u8, path, pattern);
}
};
// Ensure the prefix before the '*' matches.
if (!std.mem.startsWith(u8, path, pattern[0..star_pos])) {
return false;
}
const suffix_pattern = pattern[star_pos + 1 ..];
if (suffix_pattern.len == 0) return true;
var i: usize = star_pos;
while (i <= path.len) : (i += 1) {
if (matchPatternRecursive(suffix_pattern, path[i..], exact_match)) {
return true;
}
}
return false;
}
/// There are rules for how the pattern in robots.txt should be matched.
///
/// * should match 0 or more of any character.
/// $ should signify the end of a path, making it exact.
/// otherwise, it is a prefix path.
fn matchPattern(pattern: []const u8, path: []const u8) ?usize {
if (pattern.len == 0) return 0;
const exact_match = pattern[pattern.len - 1] == '$';
const inner_pattern = if (exact_match) pattern[0 .. pattern.len - 1] else pattern;
fn matchPattern(compiled: CompiledPattern, path: []const u8) bool {
switch (compiled.ty) {
.prefix => return std.mem.startsWith(u8, path, compiled.pattern),
.exact => {
const pattern = compiled.pattern;
return std.mem.eql(u8, path, pattern[0 .. pattern.len - 1]);
},
.wildcard => {
const pattern = compiled.pattern;
const exact_match = pattern[pattern.len - 1] == '$';
const inner_pattern = if (exact_match) pattern[0 .. pattern.len - 1] else pattern;
return matchInnerPattern(inner_pattern, path, exact_match);
},
}
}
if (matchPatternRecursive(
inner_pattern,
path,
exact_match,
)) return pattern.len else return null;
fn matchInnerPattern(pattern: []const u8, path: []const u8, exact_match: bool) bool {
var pattern_idx: usize = 0;
var path_idx: usize = 0;
var star_pattern_idx: ?usize = null;
var star_path_idx: ?usize = null;
while (pattern_idx < pattern.len or path_idx < path.len) {
// 1: If pattern is consumed and we are doing prefix match, we matched.
if (pattern_idx >= pattern.len and !exact_match) {
return true;
}
// 2: Current character is a wildcard
if (pattern_idx < pattern.len and pattern[pattern_idx] == '*') {
star_pattern_idx = pattern_idx;
star_path_idx = path_idx;
pattern_idx += 1;
continue;
}
// 3: Characters match, advance both heads.
if (pattern_idx < pattern.len and path_idx < path.len and pattern[pattern_idx] == path[path_idx]) {
pattern_idx += 1;
path_idx += 1;
continue;
}
// 4: we have a previous wildcard, backtrack and try matching more.
if (star_pattern_idx) |star_p_idx| {
// if we have exhausted the path,
// we know we haven't matched.
if (star_path_idx.? > path.len) {
return false;
}
pattern_idx = star_p_idx + 1;
path_idx = star_path_idx.?;
star_path_idx.? += 1;
continue;
}
// Fallthrough: No match and no backtracking.
return false;
}
// Handle trailing widlcards that can match 0 characters.
while (pattern_idx < pattern.len and pattern[pattern_idx] == '*') {
pattern_idx += 1;
}
if (exact_match) {
// Both must be fully consumed.
return pattern_idx == pattern.len and path_idx == path.len;
}
// For prefix match, pattern must be completed.
return pattern_idx == pattern.len;
}
pub fn isAllowed(self: *const Robots, path: []const u8) bool {
const rules = self.rules;
var longest_match_len: usize = 0;
var is_allowed_result = true;
for (rules) |rule| {
for (self.rules) |rule| {
switch (rule) {
.allow => |pattern| {
if (matchPattern(pattern, path)) |len| {
// Longest or Last Wins.
if (len >= longest_match_len) {
longest_match_len = len;
is_allowed_result = true;
}
}
},
.disallow => |pattern| {
if (pattern.len == 0) continue;
if (matchPattern(pattern, path)) |len| {
// Longest or Last Wins.
if (len >= longest_match_len) {
longest_match_len = len;
is_allowed_result = false;
}
}
},
.allow => |compiled| if (matchPattern(compiled, path)) return true,
.disallow => |compiled| if (matchPattern(compiled, path)) return false,
}
}
return is_allowed_result;
return true;
}
fn testMatch(pattern: []const u8, path: []const u8) bool {
comptime if (!builtin.is_test) unreachable;
return matchPattern(CompiledPattern.compile(pattern), path);
}
test "Robots: simple robots.txt" {
@@ -362,77 +481,77 @@ test "Robots: simple robots.txt" {
}
try std.testing.expectEqual(1, rules.len);
try std.testing.expectEqualStrings("/admin/", rules[0].disallow);
try std.testing.expectEqualStrings("/admin/", rules[0].disallow.pattern);
}
test "Robots: matchPattern - simple prefix" {
try std.testing.expect(matchPattern("/admin", "/admin/page") != null);
try std.testing.expect(matchPattern("/admin", "/admin") != null);
try std.testing.expect(matchPattern("/admin", "/other") == null);
try std.testing.expect(matchPattern("/admin/page", "/admin") == null);
try std.testing.expect(testMatch("/admin", "/admin/page"));
try std.testing.expect(testMatch("/admin", "/admin"));
try std.testing.expect(!testMatch("/admin", "/other"));
try std.testing.expect(!testMatch("/admin/page", "/admin"));
}
test "Robots: matchPattern - single wildcard" {
try std.testing.expect(matchPattern("/admin/*", "/admin/") != null);
try std.testing.expect(matchPattern("/admin/*", "/admin/page") != null);
try std.testing.expect(matchPattern("/admin/*", "/admin/page/subpage") != null);
try std.testing.expect(matchPattern("/admin/*", "/other/page") == null);
try std.testing.expect(testMatch("/admin/*", "/admin/"));
try std.testing.expect(testMatch("/admin/*", "/admin/page"));
try std.testing.expect(testMatch("/admin/*", "/admin/page/subpage"));
try std.testing.expect(!testMatch("/admin/*", "/other/page"));
}
test "Robots: matchPattern - wildcard in middle" {
try std.testing.expect(matchPattern("/abc/*/xyz", "/abc/def/xyz") != null);
try std.testing.expect(matchPattern("/abc/*/xyz", "/abc/def/ghi/xyz") != null);
try std.testing.expect(matchPattern("/abc/*/xyz", "/abc/def") == null);
try std.testing.expect(matchPattern("/abc/*/xyz", "/other/def/xyz") == null);
try std.testing.expect(testMatch("/abc/*/xyz", "/abc/def/xyz"));
try std.testing.expect(testMatch("/abc/*/xyz", "/abc/def/ghi/xyz"));
try std.testing.expect(!testMatch("/abc/*/xyz", "/abc/def"));
try std.testing.expect(!testMatch("/abc/*/xyz", "/other/def/xyz"));
}
test "Robots: matchPattern - complex wildcard case" {
try std.testing.expect(matchPattern("/abc/*/def/xyz", "/abc/def/def/xyz") != null);
try std.testing.expect(matchPattern("/abc/*/def/xyz", "/abc/ANYTHING/def/xyz") != null);
try std.testing.expect(testMatch("/abc/*/def/xyz", "/abc/def/def/xyz"));
try std.testing.expect(testMatch("/abc/*/def/xyz", "/abc/ANYTHING/def/xyz"));
}
test "Robots: matchPattern - multiple wildcards" {
try std.testing.expect(matchPattern("/a/*/b/*/c", "/a/x/b/y/c") != null);
try std.testing.expect(matchPattern("/a/*/b/*/c", "/a/x/y/b/z/w/c") != null);
try std.testing.expect(matchPattern("/*.php", "/index.php") != null);
try std.testing.expect(matchPattern("/*.php", "/admin/index.php") != null);
try std.testing.expect(testMatch("/a/*/b/*/c", "/a/x/b/y/c"));
try std.testing.expect(testMatch("/a/*/b/*/c", "/a/x/y/b/z/w/c"));
try std.testing.expect(testMatch("/*.php", "/index.php"));
try std.testing.expect(testMatch("/*.php", "/admin/index.php"));
}
test "Robots: matchPattern - end anchor" {
try std.testing.expect(matchPattern("/*.php$", "/index.php") != null);
try std.testing.expect(matchPattern("/*.php$", "/index.php?param=value") == null);
try std.testing.expect(matchPattern("/admin$", "/admin") != null);
try std.testing.expect(matchPattern("/admin$", "/admin/") == null);
try std.testing.expect(matchPattern("/fish$", "/fish") != null);
try std.testing.expect(matchPattern("/fish$", "/fishheads") == null);
try std.testing.expect(testMatch("/*.php$", "/index.php"));
try std.testing.expect(!testMatch("/*.php$", "/index.php?param=value"));
try std.testing.expect(testMatch("/admin$", "/admin"));
try std.testing.expect(!testMatch("/admin$", "/admin/"));
try std.testing.expect(testMatch("/fish$", "/fish"));
try std.testing.expect(!testMatch("/fish$", "/fishheads"));
}
test "Robots: matchPattern - wildcard with extension" {
try std.testing.expect(matchPattern("/fish*.php", "/fish.php") != null);
try std.testing.expect(matchPattern("/fish*.php", "/fishheads.php") != null);
try std.testing.expect(matchPattern("/fish*.php", "/fish/salmon.php") != null);
try std.testing.expect(matchPattern("/fish*.php", "/fish.asp") == null);
try std.testing.expect(testMatch("/fish*.php", "/fish.php"));
try std.testing.expect(testMatch("/fish*.php", "/fishheads.php"));
try std.testing.expect(testMatch("/fish*.php", "/fish/salmon.php"));
try std.testing.expect(!testMatch("/fish*.php", "/fish.asp"));
}
test "Robots: matchPattern - empty and edge cases" {
try std.testing.expect(matchPattern("", "/anything") != null);
try std.testing.expect(matchPattern("/", "/") != null);
try std.testing.expect(matchPattern("*", "/anything") != null);
try std.testing.expect(matchPattern("/*", "/anything") != null);
try std.testing.expect(matchPattern("$", "") != null);
try std.testing.expect(testMatch("", "/anything"));
try std.testing.expect(testMatch("/", "/"));
try std.testing.expect(testMatch("*", "/anything"));
try std.testing.expect(testMatch("/*", "/anything"));
try std.testing.expect(testMatch("$", ""));
}
test "Robots: matchPattern - real world examples" {
try std.testing.expect(matchPattern("/", "/anything") != null);
try std.testing.expect(testMatch("/", "/anything"));
try std.testing.expect(matchPattern("/admin/", "/admin/page") != null);
try std.testing.expect(matchPattern("/admin/", "/public/page") == null);
try std.testing.expect(testMatch("/admin/", "/admin/page"));
try std.testing.expect(!testMatch("/admin/", "/public/page"));
try std.testing.expect(matchPattern("/*.pdf$", "/document.pdf") != null);
try std.testing.expect(matchPattern("/*.pdf$", "/document.pdf.bak") == null);
try std.testing.expect(testMatch("/*.pdf$", "/document.pdf"));
try std.testing.expect(!testMatch("/*.pdf$", "/document.pdf.bak"));
try std.testing.expect(matchPattern("/*?", "/page?param=value") != null);
try std.testing.expect(matchPattern("/*?", "/page") == null);
try std.testing.expect(testMatch("/*?", "/page?param=value"));
try std.testing.expect(!testMatch("/*?", "/page"));
}
test "Robots: isAllowed - basic allow/disallow" {
@@ -675,7 +794,7 @@ test "Robots: isAllowed - complex real-world example" {
try std.testing.expect(robots.isAllowed("/cgi-bin/script.sh") == true);
}
test "Robots: isAllowed - order doesn't matter for same length" {
test "Robots: isAllowed - order doesn't matter + allow wins" {
const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "Bot",
@@ -687,7 +806,7 @@ test "Robots: isAllowed - order doesn't matter for same length" {
);
defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/page") == false);
try std.testing.expect(robots.isAllowed("/page") == true);
}
test "Robots: isAllowed - empty file uses wildcard defaults" {

View File

@@ -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),
@@ -620,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,
@@ -684,7 +682,15 @@ pub const Script = struct {
});
}
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);
@@ -897,7 +903,7 @@ 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,
@@ -956,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

@@ -65,18 +65,23 @@ 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.* = .{
.page = null,
.arena = arena,
.history = .{},
.navigation = .{},
// The prototype (EventTarget) for Navigation is created when a Page is created.
.navigation = .{ ._proto = undefined },
.storage_shed = .{},
.browser = browser,
.notification = notification,
.arena = session_allocator,
.transfer_arena = transfer_arena,
.cookie_jar = storage.Cookie.Jar.init(allocator),
.transfer_arena = browser.transfer_arena.allocator(),
};
}
@@ -84,8 +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.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,
@@ -93,8 +102,6 @@ pub fn deinit(self: *Session) void {
pub fn createPage(self: *Session) !*Page {
lp.assert(self.page == null, "Session.createPage - page not null", .{});
_ = self.browser.page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, self);
@@ -127,6 +134,21 @@ 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);
}
@@ -158,7 +180,7 @@ pub fn wait(self: *Session, wait_ms: u32) WaitResult {
}
fn processScheduledNavigation(self: *Session) !void {
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

View File

@@ -23,6 +23,7 @@ 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");
@@ -33,25 +34,24 @@ const CALL_ARENA_RETAIN = 1024 * 16;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Caller = @This();
local: js.Local,
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_handle = v8.v8__Isolate__GetCurrentContext(v8_isolate);
const embedder_data = v8.v8__Context__GetEmbedderData(v8_context_handle, 1);
var lossless: bool = undefined;
const ctx: *Context = @ptrFromInt(v8.v8__BigInt__Uint64Value(embedder_data, &lossless));
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_handle.?,
.handle = v8_context,
.call_arena = ctx.call_arena,
.isolate = .{ .handle = v8_isolate },
.isolate = ctx.isolate,
},
.prev_local = ctx.local,
.prev_context = ctx.page.js,
@@ -92,25 +92,28 @@ pub const CallOpts = struct {
};
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(self.local.isolate);
hs.init(local.isolate);
defer hs.deinit();
const info = FunctionCallbackInfo{ .handle = handle };
if (!info.isConstructCall()) {
self.handleError(T, @TypeOf(func), error.InvalidArgument, info, opts);
handleError(T, @TypeOf(func), local, error.InvalidArgument, info, opts);
return;
}
self._constructor(func, info) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
handleError(T, @TypeOf(func), local, err, info, opts);
};
}
fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void {
const F = @TypeOf(func);
const args = try self.getArgs(F, 0, info);
const 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 {
@@ -118,12 +121,12 @@ fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void
};
const new_this_handle = info.getThis();
var this = js.Object{ .local = &self.local, .handle = new_this_handle };
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 self.local.mapZigInstanceToJs(new_this_handle, non_error_res);
this = try local.mapZigInstanceToJs(new_this_handle, non_error_res);
} else {
this = try self.local.mapZigInstanceToJs(new_this_handle, res);
this = try local.mapZigInstanceToJs(new_this_handle, res);
}
// If we got back a different object (existing wrapper), copy the prototype
@@ -140,144 +143,115 @@ fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void
info.getReturnValue().set(this.handle);
}
pub fn method(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = FunctionCallbackInfo{ .handle = handle };
self._method(T, func, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
};
}
fn _method(self: *Caller, comptime T: type, func: anytype, info: FunctionCallbackInfo, comptime opts: CallOpts) !void {
const F = @TypeOf(func);
var args = try self.getArgs(F, 1, info);
const js_this = info.getThis();
@field(args, "0") = try TaggedOpaque.fromJS(*T, js_this);
const res = @call(.auto, func, args);
const mapped = try self.local.zigValueToJs(res, opts);
const return_value = info.getReturnValue();
return_value.set(mapped);
}
pub fn function(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = FunctionCallbackInfo{ .handle = handle };
self._function(func, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
};
}
fn _function(self: *Caller, func: anytype, info: FunctionCallbackInfo, comptime opts: CallOpts) !void {
const F = @TypeOf(func);
const args = try self.getArgs(F, 0, info);
const res = @call(.auto, func, args);
info.getReturnValue().set(try self.local.zigValueToJs(res, opts));
}
pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return self._getIndex(T, func, idx, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
return _getIndex(T, local, func, idx, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
};
}
fn _getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args = try self.getArgs(F, 2, info);
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 self.handleIndexedReturn(T, F, true, ret, info, opts);
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(self.local.isolate);
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return self._getNamedIndex(T, func, name, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
return _getNamedIndex(T, local, func, name, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
};
}
fn _getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
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 = try self.getArgs(F, 2, info);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try self.nameToString(@TypeOf(args.@"1"), name);
@field(args, "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 self.handleIndexedReturn(T, F, true, ret, info, opts);
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(self.local.isolate);
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return self._setNamedIndex(T, func, name, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
return _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(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
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 self.nameToString(@TypeOf(args.@"1"), name);
@field(args, "2") = try self.local.jsValueToZig(@TypeOf(@field(args, "2")), js_value);
@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") = self.local.ctx.page;
@field(args, "3") = local.ctx.page;
}
const ret = @call(.auto, func, args);
return self.handleIndexedReturn(T, F, false, ret, info, opts);
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(self.local.isolate);
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return self._deleteNamedIndex(T, func, name, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
return _deleteNamedIndex(T, local, func, name, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
return 0;
};
}
fn _deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
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 self.nameToString(@TypeOf(args.@"1"), name);
@field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name);
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = self.local.ctx.page;
@field(args, "2") = local.ctx.page;
}
const ret = @call(.auto, func, args);
return self.handleIndexedReturn(T, F, false, ret, info, opts);
return handleIndexedReturn(T, F, false, local, ret, info, opts);
}
fn handleIndexedReturn(self: *Caller, comptime T: type, comptime F: type, comptime getter: bool, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
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))) {
@@ -292,7 +266,7 @@ fn handleIndexedReturn(self: *Caller, comptime T: type, comptime F: type, compti
return 0;
}
}
self.handleError(T, F, err, info, opts);
handleError(T, F, local, err, info, opts);
// not intercepted
return 0;
};
@@ -301,7 +275,7 @@ fn handleIndexedReturn(self: *Caller, comptime T: type, comptime F: type, compti
};
if (comptime getter) {
info.getReturnValue().set(try self.local.zigValueToJs(non_error_ret, opts));
info.getReturnValue().set(try local.zigValueToJs(non_error_ret, opts));
}
// intercepted
return 1;
@@ -314,23 +288,23 @@ fn isInErrorSet(err: anyerror, comptime T: type) bool {
return false;
}
fn nameToString(self: *const Caller, comptime T: type, name: *const v8.Name) !T {
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 = &self.local, .handle = handle }, false);
return js.String.toSSO(.{ .local = local, .handle = handle }, false);
}
if (T == string.Global) {
return js.String.toSSO(.{ .local = &self.local, .handle = handle }, true);
return js.String.toSSO(.{ .local = local, .handle = handle }, true);
}
return try js.String.toSlice(.{ .local = &self.local, .handle = handle });
return try js.String.toSlice(.{ .local = local, .handle = handle });
}
fn handleError(self: *Caller, comptime T: type, comptime F: type, err: anyerror, info: anytype, comptime opts: CallOpts) void {
const isolate = self.local.isolate;
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)) {
self.logFunctionCallError(@typeName(T), @typeName(F), err, info);
logFunctionCallError(local, @typeName(T), @typeName(F), err, info);
}
}
@@ -343,7 +317,7 @@ fn handleError(self: *Caller, comptime T: type, comptime F: type, err: anyerror,
if (comptime opts.dom_exception) {
const DOMException = @import("../webapi/DOMException.zig");
if (DOMException.fromError(err)) |ex| {
const value = self.local.zigValueToJs(ex, .{}) catch break :blk isolate.createError("internal error");
const value = local.zigValueToJs(ex, .{}) catch break :blk isolate.createError("internal error");
break :blk value.handle;
}
}
@@ -355,120 +329,20 @@ fn handleError(self: *Caller, comptime T: type, comptime F: type, err: anyerror,
info.getReturnValue().setValueHandle(js_exception);
}
// If we call a method in javascript: cat.lives('nine');
//
// Then we'd expect a Zig function with 2 parameters: a self and the string.
// In this case, offset == 1. Offset is always 1 for setters or methods.
//
// Offset is always 0 for constructors.
//
// For constructors, setters and methods, we can further increase offset + 1
// if the first parameter is an instance of Page.
//
// Finally, if the JS function is called with _more_ parameters and
// the last parameter in Zig is an array, we'll try to slurp the additional
// parameters into the array.
fn getArgs(self: *const Caller, comptime F: type, comptime offset: usize, info: anytype) !ParameterTypes(F) {
const local = &self.local;
var args: ParameterTypes(F) = undefined;
const params = @typeInfo(F).@"fn".params[offset..];
// Except for the constructor, the first parameter is always `self`
// This isn't something we'll bind from JS, so skip it.
const params_to_map = blk: {
if (params.len == 0) {
return args;
}
// If the last parameter is the Page, set it, and exclude it
// from our params slice, because we don't want to bind it to
// a JS argument
if (comptime isPage(params[params.len - 1].type.?)) {
@field(args, tupleFieldName(params.len - 1 + offset)) = local.ctx.page;
break :blk params[0 .. params.len - 1];
}
// we have neither a Page nor a JsObject. All params must be
// bound to a JavaScript value.
break :blk params;
};
if (params_to_map.len == 0) {
return args;
}
const js_parameter_count = info.length();
const last_js_parameter = params_to_map.len - 1;
var is_variadic = false;
{
// This is going to get complicated. If the last Zig parameter
// is a slice AND the corresponding javascript parameter is
// NOT an an array, then we'll treat it as a variadic.
const last_parameter_type = params_to_map[params_to_map.len - 1].type.?;
const last_parameter_type_info = @typeInfo(last_parameter_type);
if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) {
const slice_type = last_parameter_type_info.pointer.child;
const corresponding_js_value = info.getArg(@intCast(last_js_parameter), local);
if (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8) {
is_variadic = true;
if (js_parameter_count == 0) {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
} else if (js_parameter_count >= params_to_map.len) {
const arr = try local.call_arena.alloc(last_parameter_type_info.pointer.child, js_parameter_count - params_to_map.len + 1);
for (arr, last_js_parameter..) |*a, i| {
a.* = try local.jsValueToZig(slice_type, info.getArg(@intCast(i), local));
}
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = arr;
} else {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
}
}
}
}
inline for (params_to_map, 0..) |param, i| {
const field_index = comptime i + offset;
if (comptime i == params_to_map.len - 1) {
if (is_variadic) {
break;
}
}
if (comptime isPage(param.type.?)) {
@compileError("Page must be the last parameter (or 2nd last if there's a JsThis): " ++ @typeName(F));
} else if (i >= js_parameter_count) {
if (@typeInfo(param.type.?) != .optional) {
return error.InvalidArgument;
}
@field(args, tupleFieldName(field_index)) = null;
} else {
const js_val = info.getArg(@intCast(i), local);
@field(args, tupleFieldName(field_index)) = local.jsValueToZig(param.type.?, js_val) catch {
return error.InvalidArgument;
};
}
}
return args;
}
// This is extracted to speed up compilation. When left inlined in handleError,
// this can add as much as 10 seconds of compilation time.
fn logFunctionCallError(self: *Caller, type_name: []const u8, func: []const u8, err: anyerror, info: FunctionCallbackInfo) void {
const args_dump = self.serializeFunctionArgs(info) catch "failed to serialize args";
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 = self.local.stackTrace() catch |err1| @errorName(err1),
.stack = local.stackTrace() catch |err1| @errorName(err1),
});
}
fn serializeFunctionArgs(self: *Caller, info: FunctionCallbackInfo) ![]const u8 {
const local = &self.local;
fn serializeFunctionArgs(local: *const Local, info: FunctionCallbackInfo) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(local.call_arena);
const separator = log.separator();
@@ -585,3 +459,274 @@ const ReturnValue = struct {
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;
}

View File

@@ -148,7 +148,7 @@ const ModuleEntry = struct {
resolver_promise: ?js.Promise.Global = null,
};
fn fromC(c_context: *const v8.Context) *Context {
pub fn fromC(c_context: *const v8.Context) *Context {
const data = v8.v8__Context__GetEmbedderData(c_context, 1).?;
const big_int = js.BigInt{ .handle = @ptrCast(data) };
return @ptrFromInt(big_int.getUint64());

View File

@@ -39,6 +39,14 @@ const JsApis = bridge.JsApis;
const Allocator = std.mem.Allocator;
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,
// and it's where we'll start ExecutionWorlds, which actually execute JavaScript.
@@ -73,11 +81,26 @@ 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;
@@ -114,13 +137,13 @@ pub fn init(app: *App, opts: InitOpts) !Env {
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;
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
@@ -153,6 +176,8 @@ pub fn init(app: *App, opts: InitOpts) !Env {
.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;
@@ -169,8 +194,9 @@ pub fn init(app: *App, opts: InitOpts) !Env {
.templates = templates,
.isolate_params = params,
.inspector = inspector,
.eternal_function_templates = eternal_function_templates,
.global_template = global_eternal,
.private_symbols = private_symbols,
.eternal_function_templates = eternal_function_templates,
};
}
@@ -191,6 +217,7 @@ pub fn deinit(self: *Env) void {
allocator.free(self.templates);
allocator.free(self.eternal_function_templates);
self.private_symbols.deinit();
self.isolate.exit();
self.isolate.deinit();
@@ -209,14 +236,31 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context {
// 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);
// our window wrapped in a v8::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);
@@ -405,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

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

@@ -364,9 +364,8 @@ pub fn getTaggedOpaque(value: *const v8.Value) ?*TaggedOpaque {
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 {

View File

@@ -75,6 +75,12 @@ pub fn newArray(self: *const Local, len: u32) js.Array {
};
}
/// Creates a new typed array. Memory is owned by JS context.
/// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Typed_arrays
pub fn createTypedArray(self: *const Local, comptime array_type: js.ArrayType, size: usize) js.ArrayBufferRef(array_type) {
return .init(self, size);
}
pub fn runMicrotasks(self: *const Local) void {
self.isolate.performMicrotasksCheckpoint();
}
@@ -181,11 +187,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
.subtype = if (@hasDecl(JsApi.Meta, "subtype")) JsApi.Meta.subype else .node,
};
// Skip setting internal field for the global object (Window)
// Window accessors get the instance from context.page.window instead
// if (resolved.class_id != @import("../webapi/Window.zig").JsApi.Meta.class_id) {
v8.v8__Object__SetInternalField(js_obj.handle, 0, isolate.createExternal(tao));
// }
v8.v8__Object__SetAlignedPointerInInternalField(js_obj.handle, 0, tao);
} else {
// If the struct is empty, we don't need to do all
// the TOA stuff and setting the internal data.
@@ -310,6 +312,15 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts)
js.Value => return value,
js.Exception => return .{ .local = self, .handle = isolate.throwException(value.handle) },
js.ArrayBufferRef(.int8).Global, js.ArrayBufferRef(.uint8).Global,
js.ArrayBufferRef(.uint8_clamped).Global, js.ArrayBufferRef(.int16).Global,
js.ArrayBufferRef(.uint16).Global, js.ArrayBufferRef(.int32).Global,
js.ArrayBufferRef(.uint32).Global, js.ArrayBufferRef(.float16).Global,
js.ArrayBufferRef(.float32).Global, js.ArrayBufferRef(.float64).Global,
=> {
return .{ .local = self, .handle = value.local(self).handle };
},
inline
js.Function,
js.Object,
@@ -1164,6 +1175,9 @@ fn _debugValue(self: *const Local, js_val: js.Value, seen: *std.AutoHashMapUnman
if (js_val.isSymbol()) {
const symbol_handle = v8.v8__Symbol__Description(@ptrCast(js_val.handle), self.isolate.handle).?;
if (v8.v8__Value__IsUndefined(symbol_handle)) {
return writer.writeAll("undefined (symbol)");
}
return writer.print("{f} (symbol)", .{js.String{ .local = self, .handle = @ptrCast(symbol_handle) }});
}
const js_val_str = try js_val.toStringSlice();

View File

@@ -0,0 +1,42 @@
// 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 Private = @This();
// 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,
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);
var global: v8.Global = undefined;
v8.v8__Global__New(isolate, private_handle, &global);
return .{
.handle = global,
};
}
pub fn deinit(self: *Private) void {
v8.v8__Global__Reset(&self.handle);
}

View File

@@ -202,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);
}
}
}
@@ -429,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));
@@ -439,6 +446,44 @@ 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);

View File

@@ -95,33 +95,6 @@ pub fn fromJS(comptime R: type, js_obj_handle: *const v8.Object) !R {
}
const internal_field_count = v8.v8__Object__InternalFieldCount(js_obj_handle);
// Special case for Window: the global object doesn't have internal fields
// Window instance is stored in context.page.window instead
if (internal_field_count == 0) {
// Normally, this would be an error. All JsObject that map to a Zig type
// are either `empty_with_no_proto` (handled above) or have an
// interalFieldCount. The only exception to that is the Window...
const isolate = v8.v8__Object__GetIsolate(js_obj_handle).?;
const context = js.Context.fromIsolate(.{ .handle = isolate });
const Window = @import("../webapi/Window.zig");
if (T == Window) {
return context.page.window;
}
// ... Or the window's prototype.
// We could make this all comptime-fancy, but it's easier to hard-code
// the EventTarget
const EventTarget = @import("../webapi/EventTarget.zig");
if (T == EventTarget) {
return context.page.window._proto;
}
// Type not found in Window's prototype chain
return error.InvalidArgument;
}
// if it isn't an empty struct, then the v8.Object should have an
// InternalFieldCount > 0, since our toa pointer should be embedded
// at index 0 of the internal field count.
@@ -133,8 +106,8 @@ pub fn fromJS(comptime R: type, js_obj_handle: *const v8.Object) !R {
@compileError("unknown Zig type: " ++ @typeName(R));
}
const internal_field_handle = v8.v8__Object__GetInternalField(js_obj_handle, 0).?;
const tao: *TaggedOpaque = @ptrCast(@alignCast(v8.v8__External__Value(internal_field_handle)));
const 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];

View File

@@ -38,11 +38,11 @@ pub fn Builder(comptime T: type) type {
return Constructor.init(T, func, opts);
}
pub fn accessor(comptime getter: anytype, comptime setter: anytype, comptime opts: Accessor.Opts) Accessor {
pub fn accessor(comptime getter: anytype, comptime setter: anytype, comptime opts: Caller.Function.Opts) Accessor {
return Accessor.init(T, getter, setter, opts);
}
pub fn function(comptime func: anytype, comptime opts: Function.Opts) Function {
pub fn function(comptime func: anytype, comptime opts: Caller.Function.Opts) Function {
return Function.init(T, func, opts);
}
@@ -160,39 +160,17 @@ pub const Constructor = struct {
pub const Function = struct {
static: bool,
arity: usize,
cache: ?Caller.Function.Opts.Caching = null,
func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
const Opts = struct {
static: bool = false,
dom_exception: bool = false,
as_typed_array: bool = false,
null_as_undefined: bool = false,
};
fn init(comptime T: type, comptime func: anytype, comptime opts: Opts) Function {
fn init(comptime T: type, comptime func: anytype, comptime opts: Caller.Function.Opts) Function {
return .{
.cache = opts.cache,
.static = opts.static,
.arity = getArity(@TypeOf(func)),
.func = struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
if (comptime opts.static) {
caller.function(T, func, handle.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
} else {
caller.method(T, func, handle.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
}
Caller.Function.call(T, handle.?, func, opts);
}
}.wrap,
};
@@ -217,42 +195,20 @@ pub const Function = struct {
pub const Accessor = struct {
static: bool = false,
cache: ?Caller.Function.Opts.Caching = null,
getter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
setter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
const Opts = struct {
static: bool = false,
as_typed_array: bool = false,
null_as_undefined: bool = false,
dom_exception: bool = false,
};
fn init(comptime T: type, comptime getter: anytype, comptime setter: anytype, comptime opts: Opts) Accessor {
fn init(comptime T: type, comptime getter: anytype, comptime setter: anytype, comptime opts: Caller.Function.Opts) Accessor {
var accessor = Accessor{
.cache = opts.cache,
.static = opts.static,
};
if (@typeInfo(@TypeOf(getter)) != .null) {
accessor.getter = struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
if (comptime opts.static) {
caller.function(T, getter, handle.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
} else {
caller.method(T, getter, handle.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
}
Caller.Function.call(T, handle.?, getter, opts);
}
}.wrap;
}
@@ -260,16 +216,7 @@ pub const Accessor = struct {
if (@typeInfo(@TypeOf(setter)) != .null) {
accessor.setter = struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
caller.method(T, setter, handle.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
Caller.Function.call(T, handle.?, setter, opts);
}
}.wrap;
}
@@ -390,11 +337,9 @@ pub const Iterator = struct {
.async = opts.async,
.func = struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
caller.method(T, struct_or_func, handle.?, .{});
return Caller.Function.call(T, handle.?, struct_or_func, .{
.null_as_undefined = opts.null_as_undefined,
});
}
}.wrap,
};
@@ -411,12 +356,7 @@ pub const Callable = struct {
fn init(comptime T: type, comptime func: anytype, comptime opts: Opts) Callable {
return .{ .func = struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
caller.method(T, func, handle.?, .{
Caller.Function.call(T, handle.?, func, .{
.null_as_undefined = opts.null_as_undefined,
});
}
@@ -811,6 +751,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/element/html/DataList.zig"),
@import("../webapi/element/html/Dialog.zig"),
@import("../webapi/element/html/Directory.zig"),
@import("../webapi/element/html/DList.zig"),
@import("../webapi/element/html/Div.zig"),
@import("../webapi/element/html/Embed.zig"),
@import("../webapi/element/html/FieldSet.zig"),
@@ -880,6 +821,9 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/event/MouseEvent.zig"),
@import("../webapi/event/PointerEvent.zig"),
@import("../webapi/event/KeyboardEvent.zig"),
@import("../webapi/event/FocusEvent.zig"),
@import("../webapi/event/WheelEvent.zig"),
@import("../webapi/event/TextEvent.zig"),
@import("../webapi/event/PromiseRejectionEvent.zig"),
@import("../webapi/MessageChannel.zig"),
@import("../webapi/MessagePort.zig"),
@@ -917,11 +861,11 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/VisualViewport.zig"),
@import("../webapi/PerformanceObserver.zig"),
@import("../webapi/navigation/Navigation.zig"),
@import("../webapi/navigation/NavigationEventTarget.zig"),
@import("../webapi/navigation/NavigationHistoryEntry.zig"),
@import("../webapi/navigation/NavigationActivation.zig"),
@import("../webapi/canvas/CanvasRenderingContext2D.zig"),
@import("../webapi/canvas/WebGLRenderingContext.zig"),
@import("../webapi/SubtleCrypto.zig"),
@import("../webapi/Selection.zig"),
@import("../webapi/ImageData.zig"),
});

View File

@@ -77,6 +77,97 @@ pub const ArrayBuffer = struct {
}
};
pub const ArrayType = enum(u8) {
int8,
uint8,
uint8_clamped,
int16,
uint16,
int32,
uint32,
float16,
float32,
float64,
};
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,
@@ -134,8 +225,10 @@ pub fn simpleZigValueToJs(isolate: Isolate, value: anytype, comptime fail: bool,
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).?);
},

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

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

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

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

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

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

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

@@ -98,6 +98,22 @@
}
</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");

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

@@ -221,7 +221,7 @@
}
</script>
<!-- <script id="defaultChecked">
<script id="defaultChecked">
testing.expectEqual(true, $('#check1').defaultChecked)
testing.expectEqual(false, $('#check2').defaultChecked)
testing.expectEqual(true, $('#radio1').defaultChecked)
@@ -493,4 +493,4 @@
input_checked.defaultChecked = true;
testing.expectEqual(false, input_checked.checked);
}
</script> -->
</script>

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

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

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

@@ -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();
});

View File

@@ -222,3 +222,33 @@
testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState);
});
</script>
<script id=xhr_abort_callback>
testing.async(async (restore) => {
const req = new XMLHttpRequest();
let abortFired = false;
let errorFired = false;
let loadEndFired = false;
await new Promise((resolve) => {
req.onabort = () => { abortFired = true; };
req.onerror = () => { errorFired = true; };
req.onloadend = () => {
loadEndFired = true;
resolve();
};
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.onreadystatechange = (e) => {
req.abort();
}
req.send();
});
restore();
testing.expectEqual(true, abortFired);
testing.expectEqual(true, errorFired);
testing.expectEqual(true, loadEndFired);
testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState);
});
</script>

View File

@@ -22,6 +22,8 @@
testing.expectEqual(undefined, children[-1]);
testing.expectEqual(['p1', 'p2'], Array.from(children).map((n) => n.id));
testing.expectEqual(false, 10 in children);
</script>
<script id=values>

View File

@@ -210,3 +210,28 @@
testing.expectEqual('HTMLDivElement', disconnectedChild.getRootNode().__proto__.constructor.name);
testing.expectEqual('HTMLDivElement', disconnectedChild.getRootNode({ composed: true }).__proto__.constructor.name);
</script>
<div id=contains><div id=other></div></div>
<script id=contains>
{
const d1 = $('#contains');
const d2 = $('#other');
testing.expectEqual(false, d1.contains(null));
testing.expectEqual(true, d1.contains(d1));
testing.expectEqual(false, d2.contains(d1));
testing.expectEqual(true, d1.contains(d2));
testing.expectEqual(false, d1.contains(p1));
}
</script>
<script id=childNodes>
{
const d1 = $('#contains');
testing.expectEqual(true, d1.childNodes === d1.childNodes)
let c1 = d1.childNodes;
d1.removeChild(c1[0])
testing.expectEqual(0, c1.length);
testing.expectEqual(0, d1.childNodes.length);
}
</script>

View File

@@ -37,7 +37,6 @@
}
</script>
<<<<<<< HEAD
<script id="microtask_access_to_list">
{
@@ -69,3 +68,31 @@
<script>
testing.expectEqual(['mark', 'measure'], PerformanceObserver.supportedEntryTypes);
</script>
<script id="buffered_option">
{
// Clear marks from previous tests so we get a precise count
performance.clearMarks();
// Create marks BEFORE the observer
performance.mark("early1", { startTime: 1.0 });
performance.mark("early2", { startTime: 2.0 });
let receivedEntries = null;
const observer = new PerformanceObserver((list) => {
receivedEntries = list.getEntries();
});
// With buffered: true, existing marks should be delivered
observer.observe({ type: "mark", buffered: true });
testing.eventually(() => {
testing.expectEqual(true, receivedEntries !== null);
testing.expectEqual(2, receivedEntries.length);
testing.expectEqual("early1", receivedEntries[0].name);
testing.expectEqual("early2", receivedEntries[1].name);
observer.disconnect();
});
}
</script>

View File

@@ -191,6 +191,74 @@
}
</script>
<script id=toString_sameText>
{
const p = document.createElement('p');
p.textContent = 'Hello World';
const range = document.createRange();
range.setStart(p.firstChild, 3);
range.setEnd(p.firstChild, 8);
testing.expectEqual('lo Wo', range.toString());
}
</script>
<script id=toString_sameElement>
{
const div = document.createElement('div');
div.innerHTML = '<p>First</p><p>Second</p><p>Third</p>';
const range = document.createRange();
range.setStart(div, 0);
range.setEnd(div, 2);
testing.expectEqual('FirstSecond', range.toString());
}
</script>
<script id=toString_crossContainer_siblings>
{
const p = document.createElement('p');
p.appendChild(document.createTextNode('AAAA'));
p.appendChild(document.createTextNode('BBBB'));
p.appendChild(document.createTextNode('CCCC'));
const range = document.createRange();
range.setStart(p.childNodes[0], 2);
range.setEnd(p.childNodes[2], 2);
testing.expectEqual('AABBBBCC', range.toString());
}
</script>
<script id=toString_crossContainer_nested>
{
const div = document.createElement('div');
div.innerHTML = '<p>First paragraph</p><p>Second paragraph</p>';
const range = document.createRange();
range.setStart(div.querySelector('p').firstChild, 6);
range.setEnd(div.querySelectorAll('p')[1].firstChild, 6);
testing.expectEqual('paragraphSecond', range.toString());
}
</script>
<script id=toString_excludes_comments>
{
const div = document.createElement('div');
div.appendChild(document.createTextNode('before'));
div.appendChild(document.createComment('this is a comment'));
div.appendChild(document.createTextNode('after'));
const range = document.createRange();
range.selectNodeContents(div);
testing.expectEqual('beforeafter', range.toString());
}
</script>
<script id=insertNode>
{
const range = document.createRange();
@@ -743,11 +811,11 @@
// range1 start is before range2 start
testing.expectEqual(-1, range1.compareBoundaryPoints(Range.START_TO_START, range2));
// range1 start is before range2 end
testing.expectEqual(-1, range1.compareBoundaryPoints(Range.START_TO_END, range2));
// range1 end is after range2 start
testing.expectEqual(1, range1.compareBoundaryPoints(Range.END_TO_START, range2));
testing.expectEqual(1, range1.compareBoundaryPoints(Range.START_TO_END, range2));
// range1 start is before range2 end
testing.expectEqual(-1, range1.compareBoundaryPoints(Range.END_TO_START, range2));
// range1 end is before range2 end
testing.expectEqual(-1, range1.compareBoundaryPoints(Range.END_TO_END, range2));
@@ -767,11 +835,11 @@
testing.expectEqual(0, range.compareBoundaryPoints(Range.START_TO_START, range));
testing.expectEqual(0, range.compareBoundaryPoints(Range.END_TO_END, range));
// Start is before end
testing.expectEqual(-1, range.compareBoundaryPoints(Range.START_TO_END, range));
// End is after start
testing.expectEqual(1, range.compareBoundaryPoints(Range.END_TO_START, range));
testing.expectEqual(1, range.compareBoundaryPoints(Range.START_TO_END, range));
// Start is before end
testing.expectEqual(-1, range.compareBoundaryPoints(Range.END_TO_START, range));
}
</script>

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="alert">
{
// alert should be callable without error
window.alert('hello');
window.alert();
testing.expectEqual(undefined, window.alert('test'));
}
</script>
<script id="confirm">
{
// confirm returns false in headless mode
testing.expectEqual(false, window.confirm('proceed?'));
testing.expectEqual(false, window.confirm());
}
</script>
<script id="prompt">
{
// prompt returns null in headless mode
testing.expectEqual(null, window.prompt('enter value'));
testing.expectEqual(null, window.prompt('enter value', 'default'));
testing.expectEqual(null, window.prompt());
}
</script>
<script id="devicePixelRatio">
{
testing.expectEqual(1, window.devicePixelRatio);
}
</script>

View File

@@ -49,6 +49,7 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const constructor = bridge.constructor(AbortController.init, .{});

View File

@@ -157,6 +157,7 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const Prototype = EventTarget;

View File

@@ -273,6 +273,7 @@ pub const JsApi = struct {
pub const name = "CharacterData";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const data = bridge.accessor(CData.getData, CData.setData, .{});

View File

@@ -34,6 +34,7 @@ pub fn createDocumentType(_: *const DOMImplementation, qualified_name: []const u
pub fn createHTMLDocument(_: *const DOMImplementation, title: ?[]const u8, page: *Page) !*Document {
const document = (try page._factory.document(Node.Document.HTMLDocument{ ._proto = undefined })).asDocument();
document._ready_state = .complete;
document._url = "about:blank";
{
const doctype = try page._factory.node(DocumentType{
@@ -67,6 +68,7 @@ pub fn createHTMLDocument(_: *const DOMImplementation, title: ?[]const u8, page:
pub fn createDocument(_: *const DOMImplementation, namespace_: ?[]const u8, qualified_name: ?[]const u8, doctype: ?*DocumentType, page: *Page) !*Document {
// Create XML Document
const document = (try page._factory.document(Node.Document.XMLDocument{ ._proto = undefined })).asDocument();
document._url = "about:blank";
// Append doctype if provided
if (doctype) |dt| {
@@ -99,6 +101,7 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
pub const enumerable = false;
};
pub const createDocumentType = bridge.function(DOMImplementation.createDocumentType, .{ .dom_exception = true });

View File

@@ -192,6 +192,7 @@ pub const JsApi = struct {
pub const name = "NodeIterator";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const root = bridge.accessor(DOMNodeIterator.getRoot, null, .{});

View File

@@ -344,6 +344,7 @@ pub const JsApi = struct {
pub const name = "TreeWalker";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const root = bridge.accessor(DOMTreeWalker.getRoot, null, .{});

View File

@@ -44,6 +44,7 @@ const Document = @This();
_type: Type,
_proto: *Node,
_location: ?*Location = null,
_url: ?[:0]const u8 = null, // URL for documents created via DOMImplementation (about:blank)
_ready_state: ReadyState = .loading,
_current_script: ?*Element.Html.Script = null,
_elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty,
@@ -105,8 +106,8 @@ pub fn asEventTarget(self: *Document) *@import("EventTarget.zig") {
return self._proto.asEventTarget();
}
pub fn getURL(_: *const Document, page: *const Page) [:0]const u8 {
return page.url;
pub fn getURL(self: *const Document, page: *const Page) [:0]const u8 {
return self._url orelse page.url;
}
pub fn getContentType(self: *const Document) []const u8 {
@@ -131,8 +132,8 @@ pub fn createElement(self: *Document, name: []const u8, options_: ?CreateElement
if (self._type == .html) {
break :blk .{ .html, std.ascii.lowerString(&page.buf, name) };
}
// Generic and XML documents create XML elements
break :blk .{ .xml, name };
// Generic and XML documents create elements with null namespace
break :blk .{ .null, name };
};
// HTML documents are case-insensitive - lowercase the tag name
@@ -218,47 +219,16 @@ pub fn getElementById(self: *Document, id: []const u8, page: *Page) ?*Element {
return null;
}
const GetElementsByTagNameResult = union(enum) {
tag: collections.NodeLive(.tag),
tag_name: collections.NodeLive(.tag_name),
all_elements: collections.NodeLive(.all_elements),
};
pub fn getElementsByTagName(self: *Document, tag_name: []const u8, page: *Page) !GetElementsByTagNameResult {
if (tag_name.len > 256) {
// 256 seems generous.
return error.InvalidTagName;
}
pub fn getElementsByTagName(self: *Document, tag_name: []const u8, page: *Page) !Node.GetElementsByTagNameResult {
return self.asNode().getElementsByTagName(tag_name, page);
}
if (std.mem.eql(u8, tag_name, "*")) {
return .{
.all_elements = collections.NodeLive(.all_elements).init(self.asNode(), {}, page),
};
}
const lower = std.ascii.lowerString(&page.buf, tag_name);
if (Node.Element.Tag.parseForMatch(lower)) |known| {
// optimized for known tag names, comparis
return .{
.tag = collections.NodeLive(.tag).init(self.asNode(), known, page),
};
}
const arena = page.arena;
const filter = try String.init(arena, lower, .{});
return .{ .tag_name = collections.NodeLive(.tag_name).init(self.asNode(), filter, page) };
pub fn getElementsByTagNameNS(self: *Document, namespace: ?[]const u8, local_name: []const u8, page: *Page) !collections.NodeLive(.tag_name_ns) {
return self.asNode().getElementsByTagNameNS(namespace, local_name, page);
}
pub fn getElementsByClassName(self: *Document, class_name: []const u8, page: *Page) !collections.NodeLive(.class_name) {
const arena = page.arena;
// Parse space-separated class names
var class_names: std.ArrayList([]const u8) = .empty;
var it = std.mem.tokenizeAny(u8, class_name, "\t\n\x0C\r ");
while (it.next()) |name| {
try class_names.append(arena, try page.dupeString(name));
}
return collections.NodeLive(.class_name).init(self.asNode(), class_names.items, page);
return self.asNode().getElementsByClassName(class_name, page);
}
pub fn getElementsByName(self: *Document, name: []const u8, page: *Page) !collections.NodeLive(.name) {
@@ -354,19 +324,48 @@ pub fn createRange(_: *const Document, page: *Page) !*Range {
pub fn createEvent(_: *const Document, event_type: []const u8, page: *Page) !*@import("Event.zig") {
const Event = @import("Event.zig");
if (event_type.len > 100) {
return error.NotSupported;
}
const normalized = std.ascii.lowerString(&page.buf, event_type);
if (std.ascii.eqlIgnoreCase(event_type, "event") or std.ascii.eqlIgnoreCase(event_type, "events") or std.ascii.eqlIgnoreCase(event_type, "htmlevents")) {
if (std.mem.eql(u8, normalized, "event") or std.mem.eql(u8, normalized, "events") or std.mem.eql(u8, normalized, "htmlevents")) {
return Event.init("", null, page);
}
if (std.ascii.eqlIgnoreCase(event_type, "customevent") or std.ascii.eqlIgnoreCase(event_type, "customevents")) {
if (std.mem.eql(u8, normalized, "customevent") or std.mem.eql(u8, normalized, "customevents")) {
const CustomEvent = @import("event/CustomEvent.zig");
const custom_event = try CustomEvent.init("", null, page);
return custom_event.asEvent();
return (try CustomEvent.init("", null, page)).asEvent();
}
if (std.ascii.eqlIgnoreCase(event_type, "messageevent")) {
return error.NotSupported;
if (std.mem.eql(u8, normalized, "keyboardevent")) {
const KeyboardEvent = @import("event/KeyboardEvent.zig");
return (try KeyboardEvent.init("", null, page)).asEvent();
}
if (std.mem.eql(u8, normalized, "mouseevent") or std.mem.eql(u8, normalized, "mouseevents")) {
const MouseEvent = @import("event/MouseEvent.zig");
return (try MouseEvent.init("", null, page)).asEvent();
}
if (std.mem.eql(u8, normalized, "messageevent")) {
const MessageEvent = @import("event/MessageEvent.zig");
return (try MessageEvent.init("", null, page)).asEvent();
}
if (std.mem.eql(u8, normalized, "uievent") or std.mem.eql(u8, normalized, "uievents")) {
const UIEvent = @import("event/UIEvent.zig");
return (try UIEvent.init("", null, page)).asEvent();
}
if (std.mem.eql(u8, normalized, "focusevent") or std.mem.eql(u8, normalized, "focusevents")) {
const FocusEvent = @import("event/FocusEvent.zig");
return (try FocusEvent.init("", null, page)).asEvent();
}
if (std.mem.eql(u8, normalized, "textevent") or std.mem.eql(u8, normalized, "textevents")) {
const TextEvent = @import("event/TextEvent.zig");
return (try TextEvent.init("", null, page)).asEvent();
}
return error.NotSupported;
@@ -913,7 +912,8 @@ fn validateElementName(name: []const u8) !void {
const is_valid = (c >= 'a' and c <= 'z') or
(c >= 'A' and c <= 'Z') or
(c >= '0' and c <= '9') or
c == '_' or c == '-' or c == '.' or c == ':';
c == '_' or c == '-' or c == '.' or c == ':' or
c >= 128; // Allow non-ASCII UTF-8
if (!is_valid) {
return error.InvalidCharacterError;
@@ -934,6 +934,7 @@ pub const JsApi = struct {
pub const name = "Document";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const constructor = bridge.constructor(_constructor, .{});
@@ -948,6 +949,7 @@ pub const JsApi = struct {
pub const URL = bridge.accessor(Document.getURL, null, .{});
pub const documentURI = bridge.accessor(Document.getURL, null, .{});
pub const documentElement = bridge.accessor(Document.getDocumentElement, null, .{});
pub const scrollingElement = bridge.accessor(Document.getDocumentElement, null, .{});
pub const children = bridge.accessor(Document.getChildren, null, .{});
pub const readyState = bridge.accessor(Document.getReadyState, null, .{});
pub const implementation = bridge.accessor(Document.getImplementation, null, .{});
@@ -982,6 +984,7 @@ pub const JsApi = struct {
pub const querySelector = bridge.function(Document.querySelector, .{ .dom_exception = true });
pub const querySelectorAll = bridge.function(Document.querySelectorAll, .{ .dom_exception = true });
pub const getElementsByTagName = bridge.function(Document.getElementsByTagName, .{});
pub const getElementsByTagNameNS = bridge.function(Document.getElementsByTagNameNS, .{});
pub const getSelection = bridge.function(Document.getSelection, .{});
pub const getElementsByClassName = bridge.function(Document.getElementsByClassName, .{});
pub const getElementsByName = bridge.function(Document.getElementsByName, .{});

View File

@@ -233,6 +233,7 @@ pub const JsApi = struct {
pub const name = "DocumentFragment";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const constructor = bridge.constructor(DocumentFragment.init, .{});

View File

@@ -81,6 +81,7 @@ pub const JsApi = struct {
pub const name = "DocumentType";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const name = bridge.accessor(DocumentType.getName, null, .{});

View File

@@ -49,6 +49,12 @@ pub const RelListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTok
pub const ShadowRootLookup = std.AutoHashMapUnmanaged(*Element, *ShadowRoot);
pub const AssignedSlotLookup = std.AutoHashMapUnmanaged(*Element, *Html.Slot);
pub const ScrollPosition = struct {
x: u32 = 0,
y: u32 = 0,
};
pub const ScrollPositionLookup = std.AutoHashMapUnmanaged(*Element, ScrollPosition);
pub const Namespace = enum(u8) {
html,
svg,
@@ -204,6 +210,7 @@ pub fn getTagNameLower(self: *const Element) []const u8 {
.dialog => "dialog",
.directory => "dir",
.div => "div",
.dl => "dl",
.embed => "embed",
.fieldset => "fieldset",
.font => "font",
@@ -281,6 +288,7 @@ pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 {
.dialog => "DIALOG",
.directory => "DIR",
.div => "DIV",
.dl => "DL",
.embed => "EMBED",
.fieldset => "FIELDSET",
.font => "FONT",
@@ -674,6 +682,11 @@ pub fn getClassList(self: *Element, page: *Page) !*collections.DOMTokenList {
return gop.value_ptr.*;
}
pub fn setClassList(self: *Element, value: String, page: *Page) !void {
const class_list = try self.getClassList(page);
try class_list.setValue(value, page);
}
pub fn getRelList(self: *Element, page: *Page) !*collections.DOMTokenList {
const gop = try page._element_rel_lists.getOrPut(page.arena, self);
if (!gop.found_existing) {
@@ -762,25 +775,45 @@ pub fn remove(self: *Element, page: *Page) void {
}
pub fn focus(self: *Element, page: *Page) !void {
const Event = @import("Event.zig");
const FocusEvent = @import("event/FocusEvent.zig");
// Capture relatedTarget before anything changes
const old_related: ?*@import("EventTarget.zig") = if (page.document._active_element) |old| old.asEventTarget() else null;
const new_target = self.asEventTarget();
if (page.document._active_element) |old| {
if (old == self) {
return;
}
const blur_event = try Event.initTrusted(comptime .wrap("blur"), null, page);
defer if (!blur_event._v8_handoff) blur_event.deinit(false);
try page._event_manager.dispatch(old.asEventTarget(), blur_event);
const old_target = old.asEventTarget();
// Dispatch blur on old element (no bubble, composed)
const blur_event = try FocusEvent.initTrusted(comptime .wrap("blur"), .{ .composed = true, .relatedTarget = new_target }, page);
defer if (!blur_event.asEvent()._v8_handoff) blur_event.deinit(false);
try page._event_manager.dispatch(old_target, blur_event.asEvent());
// Dispatch focusout on old element (bubbles, composed)
const focusout_event = try FocusEvent.initTrusted(comptime .wrap("focusout"), .{ .bubbles = true, .composed = true, .relatedTarget = new_target }, page);
defer if (!focusout_event.asEvent()._v8_handoff) focusout_event.deinit(false);
try page._event_manager.dispatch(old_target, focusout_event.asEvent());
}
// Must be set after blur/focusout and before focus/focusin —
// event dispatch can reset _active_element if set earlier.
if (self.asNode().isConnected()) {
page.document._active_element = self;
}
const focus_event = try Event.initTrusted(comptime .wrap("focus"), null, page);
defer if (!focus_event._v8_handoff) focus_event.deinit(false);
try page._event_manager.dispatch(self.asEventTarget(), focus_event);
// Dispatch focus on new element (no bubble, composed)
const focus_event = try FocusEvent.initTrusted(comptime .wrap("focus"), .{ .composed = true, .relatedTarget = old_related }, page);
defer if (!focus_event.asEvent()._v8_handoff) focus_event.deinit(false);
try page._event_manager.dispatch(new_target, focus_event.asEvent());
// Dispatch focusin on new element (bubbles, composed)
const focusin_event = try FocusEvent.initTrusted(comptime .wrap("focusin"), .{ .bubbles = true, .composed = true, .relatedTarget = old_related }, page);
defer if (!focusin_event.asEvent()._v8_handoff) focusin_event.deinit(false);
try page._event_manager.dispatch(new_target, focusin_event.asEvent());
}
pub fn blur(self: *Element, page: *Page) !void {
@@ -788,10 +821,18 @@ pub fn blur(self: *Element, page: *Page) !void {
page.document._active_element = null;
const Event = @import("Event.zig");
const blur_event = try Event.initTrusted(comptime .wrap("blur"), null, page);
defer if (!blur_event._v8_handoff) blur_event.deinit(false);
try page._event_manager.dispatch(self.asEventTarget(), blur_event);
const FocusEvent = @import("event/FocusEvent.zig");
const old_target = self.asEventTarget();
// Dispatch blur (no bubble, composed)
const blur_event = try FocusEvent.initTrusted(comptime .wrap("blur"), .{ .composed = true }, page);
defer if (!blur_event.asEvent()._v8_handoff) blur_event.deinit(false);
try page._event_manager.dispatch(old_target, blur_event.asEvent());
// Dispatch focusout (bubbles, composed)
const focusout_event = try FocusEvent.initTrusted(comptime .wrap("focusout"), .{ .bubbles = true, .composed = true }, page);
defer if (!focusout_event.asEvent()._v8_handoff) focusout_event.deinit(false);
try page._event_manager.dispatch(old_target, focusout_event.asEvent());
}
pub fn getChildren(self: *Element, page: *Page) !collections.NodeLive(.child_elements) {
@@ -1022,6 +1063,82 @@ pub fn getClientRects(self: *Element, page: *Page) ![]DOMRect {
return ptr[0..1];
}
pub fn getScrollTop(self: *Element, page: *Page) u32 {
const pos = page._element_scroll_positions.get(self) orelse return 0;
return pos.y;
}
pub fn setScrollTop(self: *Element, value: i32, page: *Page) !void {
const gop = try page._element_scroll_positions.getOrPut(page.arena, self);
if (!gop.found_existing) {
gop.value_ptr.* = .{};
}
gop.value_ptr.y = @intCast(@max(0, value));
}
pub fn getScrollLeft(self: *Element, page: *Page) u32 {
const pos = page._element_scroll_positions.get(self) orelse return 0;
return pos.x;
}
pub fn setScrollLeft(self: *Element, value: i32, page: *Page) !void {
const gop = try page._element_scroll_positions.getOrPut(page.arena, self);
if (!gop.found_existing) {
gop.value_ptr.* = .{};
}
gop.value_ptr.x = @intCast(@max(0, value));
}
pub fn getScrollHeight(self: *Element, page: *Page) !f64 {
// In our dummy layout engine, content doesn't overflow
return self.getClientHeight(page);
}
pub fn getScrollWidth(self: *Element, page: *Page) !f64 {
// In our dummy layout engine, content doesn't overflow
return self.getClientWidth(page);
}
pub fn getOffsetHeight(self: *Element, page: *Page) !f64 {
if (!try self.checkVisibility(page)) {
return 0.0;
}
const dims = try self.getElementDimensions(page);
return dims.height;
}
pub fn getOffsetWidth(self: *Element, page: *Page) !f64 {
if (!try self.checkVisibility(page)) {
return 0.0;
}
const dims = try self.getElementDimensions(page);
return dims.width;
}
pub fn getOffsetTop(self: *Element, page: *Page) !f64 {
if (!try self.checkVisibility(page)) {
return 0.0;
}
return calculateDocumentPosition(self.asNode());
}
pub fn getOffsetLeft(self: *Element, page: *Page) !f64 {
if (!try self.checkVisibility(page)) {
return 0.0;
}
return calculateSiblingPosition(self.asNode());
}
pub fn getClientTop(_: *Element) f64 {
// Border width - in our dummy layout, we don't apply borders to layout
return 0.0;
}
pub fn getClientLeft(_: *Element) f64 {
// Border width - in our dummy layout, we don't apply borders to layout
return 0.0;
}
// Calculates document position by counting all nodes that appear before this one
// in tree order, but only traversing the "left side" of the tree.
//
@@ -1105,47 +1222,16 @@ fn calculateSiblingPosition(node: *Node) f64 {
return position * 5.0; // 5px per node
}
const GetElementsByTagNameResult = union(enum) {
tag: collections.NodeLive(.tag),
tag_name: collections.NodeLive(.tag_name),
all_elements: collections.NodeLive(.all_elements),
};
pub fn getElementsByTagName(self: *Element, tag_name: []const u8, page: *Page) !GetElementsByTagNameResult {
if (tag_name.len > 256) {
// 256 seems generous.
return error.InvalidTagName;
}
pub fn getElementsByTagName(self: *Element, tag_name: []const u8, page: *Page) !Node.GetElementsByTagNameResult {
return self.asNode().getElementsByTagName(tag_name, page);
}
if (std.mem.eql(u8, tag_name, "*")) {
return .{
.all_elements = collections.NodeLive(.all_elements).init(self.asNode(), {}, page),
};
}
const lower = std.ascii.lowerString(&page.buf, tag_name);
if (Tag.parseForMatch(lower)) |known| {
// optimized for known tag names
return .{
.tag = collections.NodeLive(.tag).init(self.asNode(), known, page),
};
}
const arena = page.arena;
const filter = try String.init(arena, lower, .{});
return .{ .tag_name = collections.NodeLive(.tag_name).init(self.asNode(), filter, page) };
pub fn getElementsByTagNameNS(self: *Element, namespace: ?[]const u8, local_name: []const u8, page: *Page) !collections.NodeLive(.tag_name_ns) {
return self.asNode().getElementsByTagNameNS(namespace, local_name, page);
}
pub fn getElementsByClassName(self: *Element, class_name: []const u8, page: *Page) !collections.NodeLive(.class_name) {
const arena = page.arena;
// Parse space-separated class names
var class_names: std.ArrayList([]const u8) = .empty;
var it = std.mem.tokenizeAny(u8, class_name, "\t\n\x0C\r ");
while (it.next()) |name| {
try class_names.append(arena, try page.dupeString(name));
}
return collections.NodeLive(.class_name).init(self.asNode(), class_names.items, page);
return self.asNode().getElementsByClassName(class_name, page);
}
pub fn clone(self: *Element, deep: bool, page: *Page) !*Node {
@@ -1219,6 +1305,7 @@ pub fn getTag(self: *const Element) Tag {
.area => .area,
.base => .base,
.div => .div,
.dl => .dl,
.embed => .embed,
.form => .form,
.p => .p,
@@ -1425,6 +1512,7 @@ pub const JsApi = struct {
pub const name = "Element";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const tagName = bridge.accessor(_tagName, null, .{});
@@ -1479,7 +1567,7 @@ pub const JsApi = struct {
pub const slot = bridge.accessor(Element.getSlot, Element.setSlot, .{});
pub const dir = bridge.accessor(Element.getDir, Element.setDir, .{});
pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{});
pub const classList = bridge.accessor(Element.getClassList, null, .{});
pub const classList = bridge.accessor(Element.getClassList, Element.setClassList, .{});
pub const dataset = bridge.accessor(Element.getDataset, null, .{});
pub const style = bridge.accessor(Element.getStyle, null, .{});
pub const attributes = bridge.accessor(Element.getAttributeNamedNodeMap, null, .{});
@@ -1527,9 +1615,20 @@ pub const JsApi = struct {
pub const checkVisibility = bridge.function(Element.checkVisibility, .{});
pub const clientWidth = bridge.accessor(Element.getClientWidth, null, .{});
pub const clientHeight = bridge.accessor(Element.getClientHeight, null, .{});
pub const clientTop = bridge.accessor(Element.getClientTop, null, .{});
pub const clientLeft = bridge.accessor(Element.getClientLeft, null, .{});
pub const scrollTop = bridge.accessor(Element.getScrollTop, Element.setScrollTop, .{});
pub const scrollLeft = bridge.accessor(Element.getScrollLeft, Element.setScrollLeft, .{});
pub const scrollHeight = bridge.accessor(Element.getScrollHeight, null, .{});
pub const scrollWidth = bridge.accessor(Element.getScrollWidth, null, .{});
pub const offsetTop = bridge.accessor(Element.getOffsetTop, null, .{});
pub const offsetLeft = bridge.accessor(Element.getOffsetLeft, null, .{});
pub const offsetWidth = bridge.accessor(Element.getOffsetWidth, null, .{});
pub const offsetHeight = bridge.accessor(Element.getOffsetHeight, null, .{});
pub const getClientRects = bridge.function(Element.getClientRects, .{});
pub const getBoundingClientRect = bridge.function(Element.getBoundingClientRect, .{});
pub const getElementsByTagName = bridge.function(Element.getElementsByTagName, .{});
pub const getElementsByTagNameNS = bridge.function(Element.getElementsByTagNameNS, .{});
pub const getElementsByClassName = bridge.function(Element.getElementsByClassName, .{});
pub const children = bridge.accessor(Element.getChildren, null, .{});
pub const focus = bridge.function(Element.focus, .{});

View File

@@ -409,6 +409,7 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(Event.deinit);
pub const enumerable = false;
};
pub const constructor = bridge.constructor(Event.init, .{});

View File

@@ -39,7 +39,7 @@ pub const Type = union(enum) {
media_query_list: *@import("css/MediaQueryList.zig"),
message_port: *@import("MessagePort.zig"),
text_track_cue: *@import("media/TextTrackCue.zig"),
navigation: *@import("navigation/NavigationEventTarget.zig"),
navigation: *@import("navigation/Navigation.zig"),
screen: *@import("Screen.zig"),
screen_orientation: *@import("Screen.zig").Orientation,
visual_viewport: *@import("VisualViewport.zig"),
@@ -162,6 +162,7 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const constructor = bridge.constructor(EventTarget.init, .{});

View File

@@ -0,0 +1,122 @@
// 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 String = @import("../../string.zig").String;
const log = @import("../../log.zig");
const js = @import("../js/js.zig");
const color = @import("../color.zig");
const Page = @import("../Page.zig");
/// https://developer.mozilla.org/en-US/docs/Web/API/ImageData/ImageData
const ImageData = @This();
_width: u32,
_height: u32,
_data: js.ArrayBufferRef(.uint8_clamped).Global,
pub const ConstructorSettings = struct {
/// Specifies the color space of the image data.
/// Can be set to "srgb" for the sRGB color space or "display-p3" for the display-p3 color space.
colorSpace: String = .wrap("srgb"),
/// Specifies the pixel format.
/// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createImageData#pixelformat
pixelFormat: String = .wrap("rgba-unorm8"),
};
/// This has many constructors:
///
/// ```js
/// new ImageData(width, height)
/// new ImageData(width, height, settings)
///
/// new ImageData(dataArray, width)
/// new ImageData(dataArray, width, height)
/// new ImageData(dataArray, width, height, settings)
/// ```
///
/// We currently support only the first 2.
pub fn constructor(
width: u32,
height: u32,
maybe_settings: ?ConstructorSettings,
page: *Page,
) !*ImageData {
if (width == 0 or height == 0) {
return error.IndexSizeError;
}
const settings: ConstructorSettings = maybe_settings orelse .{};
if (settings.colorSpace.eql(comptime .wrap("srgb")) == false) {
return error.TypeError;
}
if (settings.pixelFormat.eql(comptime .wrap("rgba-unorm8")) == false) {
return error.TypeError;
}
const size = width * height * 4;
return page._factory.create(ImageData{
._width = width,
._height = height,
._data = try page.js.local.?.createTypedArray(.uint8_clamped, size).persist(),
});
}
pub fn getWidth(self: *const ImageData) u32 {
return self._width;
}
pub fn getHeight(self: *const ImageData) u32 {
return self._height;
}
pub fn getPixelFormat(_: *const ImageData) String {
return comptime .wrap("rgba-unorm8");
}
pub fn getColorSpace(_: *const ImageData) String {
return comptime .wrap("srgb");
}
pub fn getData(self: *const ImageData) js.ArrayBufferRef(.uint8_clamped).Global {
return self._data;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(ImageData);
pub const Meta = struct {
pub const name = "ImageData";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(ImageData.constructor, .{ .dom_exception = true });
pub const width = bridge.accessor(ImageData.getWidth, null, .{});
pub const height = bridge.accessor(ImageData.getHeight, null, .{});
pub const pixelFormat = bridge.accessor(ImageData.getPixelFormat, null, .{});
pub const colorSpace = bridge.accessor(ImageData.getColorSpace, null, .{});
pub const data = bridge.accessor(ImageData.getData, null, .{});
};
const testing = @import("../../testing.zig");
test "WebApi: ImageData" {
try testing.htmlRunner("image_data.html", .{});
}

View File

@@ -246,7 +246,7 @@ fn checkIntersection(self: *IntersectionObserver, target: *Element, page: *Page)
._page = page,
._arena = arena,
._target = target,
._time = 0.0, // TODO: Get actual timestamp
._time = page.window._performance.now(),
._bounding_client_rect = data.bounding_client_rect,
._intersection_rect = data.intersection_rect,
._root_bounds = data.root_bounds,

View File

@@ -249,13 +249,13 @@ pub fn appendChild(self: *Node, child: *Node, page: *Page) !*Node {
return child;
}
pub fn childNodes(self: *const Node, page: *Page) !*collections.ChildNodes {
return collections.ChildNodes.init(self._children, page);
pub fn childNodes(self: *Node, page: *Page) !*collections.ChildNodes {
return collections.ChildNodes.init(self, page);
}
pub fn getTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!void {
switch (self._type) {
.element => {
.element, .document_fragment => {
var it = self.childrenIterator();
while (it.next()) |child| {
// ignore comments and processing instructions.
@@ -268,7 +268,6 @@ pub fn getTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!vo
.cdata => |c| try writer.writeAll(c.getData()),
.document => {},
.document_type => {},
.document_fragment => {},
.attribute => |attr| try writer.writeAll(attr._value.str()),
}
}
@@ -414,7 +413,9 @@ pub fn getRootNode(self: *Node, opts_: ?GetRootNodeOpts) *Node {
return root;
}
pub fn contains(self: *const Node, child: *const Node) bool {
pub fn contains(self: *const Node, child_: ?*const Node) bool {
const child = child_ orelse return false;
if (self == child) {
// yes, this is correct
return true;
@@ -846,6 +847,69 @@ fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayList(u8), pag
}
}
pub const GetElementsByTagNameResult = union(enum) {
tag: collections.NodeLive(.tag),
tag_name: collections.NodeLive(.tag_name),
all_elements: collections.NodeLive(.all_elements),
};
// Not exposed in the WebAPI, but used by both Element and Document
pub fn getElementsByTagName(self: *Node, tag_name: []const u8, page: *Page) !GetElementsByTagNameResult {
if (tag_name.len > 256) {
// 256 seems generous.
return error.InvalidTagName;
}
if (std.mem.eql(u8, tag_name, "*")) {
return .{
.all_elements = collections.NodeLive(.all_elements).init(self, {}, page),
};
}
const lower = std.ascii.lowerString(&page.buf, tag_name);
if (Node.Element.Tag.parseForMatch(lower)) |known| {
// optimized for known tag names, comparis
return .{
.tag = collections.NodeLive(.tag).init(self, known, page),
};
}
const arena = page.arena;
const filter = try String.init(arena, lower, .{});
return .{ .tag_name = collections.NodeLive(.tag_name).init(self, filter, page) };
}
// Not exposed in the WebAPI, but used by both Element and Document
pub fn getElementsByTagNameNS(self: *Node, namespace: ?[]const u8, local_name: []const u8, page: *Page) !collections.NodeLive(.tag_name_ns) {
if (local_name.len > 256) {
return error.InvalidTagName;
}
// Parse namespace - "*" means wildcard (null), null means Element.Namespace.null
const ns: ?Element.Namespace = if (namespace) |ns_str|
if (std.mem.eql(u8, ns_str, "*")) null else Element.Namespace.parse(ns_str)
else
Element.Namespace.null;
return collections.NodeLive(.tag_name_ns).init(self, .{
.namespace = ns,
.local_name = try String.init(page.arena, local_name, .{}),
}, page);
}
// Not exposed in the WebAPI, but used by both Element and Document
pub fn getElementsByClassName(self: *Node, class_name: []const u8, page: *Page) !collections.NodeLive(.class_name) {
const arena = page.arena;
// Parse space-separated class names
var class_names: std.ArrayList([]const u8) = .empty;
var it = std.mem.tokenizeAny(u8, class_name, "\t\n\x0C\r ");
while (it.next()) |name| {
try class_names.append(arena, try page.dupeString(name));
}
return collections.NodeLive(.class_name).init(self, class_names.items, page);
}
// Writes a JSON representation of the node and its children
pub fn jsonStringify(self: *const Node, writer: *std.json.Stringify) !void {
// stupid json api requires this to be const,
@@ -879,6 +943,7 @@ pub const JsApi = struct {
pub const name = "Node";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const ELEMENT_NODE = bridge.property(1, .{ .template = true });
@@ -912,16 +977,15 @@ pub const JsApi = struct {
fn _textContext(self: *Node, page: *const Page) !?[]const u8 {
// cdata and attributes can return value directly, avoiding the copy
switch (self._type) {
.element => |el| {
.element, .document_fragment => {
var buf = std.Io.Writer.Allocating.init(page.call_arena);
try el.asNode().getTextContent(&buf.writer);
try self.getTextContent(&buf.writer);
return buf.written();
},
.cdata => |cdata| return cdata.getData(),
.attribute => |attr| return attr._value.str(),
.document => return null,
.document_type => return null,
.document_fragment => return null,
}
}
@@ -932,7 +996,7 @@ pub const JsApi = struct {
pub const parentNode = bridge.accessor(Node.parentNode, null, .{});
pub const parentElement = bridge.accessor(Node.parentElement, null, .{});
pub const appendChild = bridge.function(Node.appendChild, .{ .dom_exception = true });
pub const childNodes = bridge.accessor(Node.childNodes, null, .{});
pub const childNodes = bridge.accessor(Node.childNodes, null, .{ .cache = .{ .private = "child_nodes" } });
pub const isConnected = bridge.accessor(Node.isConnected, null, .{});
pub const ownerDocument = bridge.accessor(Node.ownerDocument, null, .{});
pub const hasChildNodes = bridge.function(Node.hasChildNodes, .{});

View File

@@ -88,6 +88,7 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
pub const enumerable = false;
};
pub const FILTER_ACCEPT = bridge.property(NodeFilter.FILTER_ACCEPT, .{ .template = true });

View File

@@ -113,6 +113,20 @@ pub fn observe(
// Update interests.
self._interests = interests;
// Deliver existing entries if buffered option is set.
// Per spec, buffered is only valid with the type option, not entryTypes.
// Delivery is async via a queued task, not synchronous.
if (options.buffered and options.type != null and !self.hasRecords()) {
for (page.window._performance._entries.items) |entry| {
if (self.interested(entry)) {
try self._entries.append(page.arena, entry);
}
}
if (self.hasRecords()) {
try page.schedulePerformanceObserverDelivery();
}
}
}
pub fn disconnect(self: *PerformanceObserver, page: *Page) void {

View File

@@ -37,6 +37,10 @@ pub fn init(page: *Page) !*Range {
}
pub fn setStart(self: *Range, node: *Node, offset: u32) !void {
if (node._type == .document_type) {
return error.InvalidNodeType;
}
if (offset > node.getLength()) {
return error.IndexSizeError;
}
@@ -54,6 +58,10 @@ pub fn setStart(self: *Range, node: *Node, offset: u32) !void {
}
pub fn setEnd(self: *Range, node: *Node, offset: u32) !void {
if (node._type == .document_type) {
return error.InvalidNodeType;
}
// Validate offset
if (offset > node.getLength()) {
return error.IndexSizeError;
@@ -150,10 +158,10 @@ pub fn compareBoundaryPoints(self: *const Range, how_raw: i32, source_range: *co
source_range._proto._start_offset,
),
1 => AbstractRange.compareBoundaryPoints( // START_TO_END
self._proto._start_container,
self._proto._start_offset,
source_range._proto._end_container,
source_range._proto._end_offset,
self._proto._end_container,
self._proto._end_offset,
source_range._proto._start_container,
source_range._proto._start_offset,
),
2 => AbstractRange.compareBoundaryPoints( // END_TO_END
self._proto._end_container,
@@ -162,10 +170,10 @@ pub fn compareBoundaryPoints(self: *const Range, how_raw: i32, source_range: *co
source_range._proto._end_offset,
),
3 => AbstractRange.compareBoundaryPoints( // END_TO_START
self._proto._end_container,
self._proto._end_offset,
source_range._proto._start_container,
source_range._proto._start_offset,
self._proto._start_container,
self._proto._start_offset,
source_range._proto._end_container,
source_range._proto._end_offset,
),
else => unreachable,
};
@@ -178,10 +186,6 @@ pub fn compareBoundaryPoints(self: *const Range, how_raw: i32, source_range: *co
}
pub fn comparePoint(self: *const Range, node: *Node, offset: u32) !i16 {
if (offset > node.getLength()) {
return error.IndexSizeError;
}
// Check if node is in a different tree than the range
const node_root = node.getRootNode(null);
const start_root = self._proto._start_container.getRootNode(null);
@@ -189,6 +193,14 @@ pub fn comparePoint(self: *const Range, node: *Node, offset: u32) !i16 {
return error.WrongDocument;
}
if (node._type == .document_type) {
return error.InvalidNodeType;
}
if (offset > node.getLength()) {
return error.IndexSizeError;
}
// Compare point with start boundary
const cmp_start = AbstractRange.compareBoundaryPoints(
node,
@@ -346,6 +358,7 @@ pub fn deleteContents(self: *Range, page: *Page) !void {
if (self._proto.getCollapsed()) {
return;
}
page.domChanged();
// Simple case: same container
if (self._proto._start_container == self._proto._end_container) {
@@ -536,23 +549,93 @@ pub fn toString(self: *const Range, page: *Page) ![]const u8 {
}
fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
if (self._proto.getCollapsed()) {
return;
if (self._proto.getCollapsed()) return;
const start_node = self._proto._start_container;
const end_node = self._proto._end_container;
const start_offset = self._proto._start_offset;
const end_offset = self._proto._end_offset;
// Same text node — just substring
if (start_node == end_node) {
if (start_node.is(Node.CData)) |cdata| {
if (!isCommentOrPI(cdata)) {
const data = cdata.getData();
const s = @min(start_offset, data.len);
const e = @min(end_offset, data.len);
try writer.writeAll(data[s..e]);
}
return;
}
}
if (self._proto._start_container == self._proto._end_container) {
if (self._proto._start_container.is(Node.CData)) |cdata| {
const root = self._proto.getCommonAncestorContainer();
// Partial start: if start container is a text node, write from offset to end
if (start_node.is(Node.CData)) |cdata| {
if (!isCommentOrPI(cdata)) {
const data = cdata.getData();
if (self._proto._start_offset < data.len and self._proto._end_offset <= data.len) {
try writer.writeAll(data[self._proto._start_offset..self._proto._end_offset]);
const s = @min(start_offset, data.len);
try writer.writeAll(data[s..]);
}
}
// Walk fully-contained text nodes between the boundaries.
// For text containers, the walk starts after that node.
// For element containers, the walk starts at the child at offset.
const walk_start: ?*Node = if (start_node.is(Node.CData) != null)
nextInTreeOrder(start_node, root)
else
start_node.getChildAt(start_offset) orelse nextAfterSubtree(start_node, root);
const walk_end: ?*Node = if (end_node.is(Node.CData) != null)
end_node
else
end_node.getChildAt(end_offset) orelse nextAfterSubtree(end_node, root);
if (walk_start) |start| {
var current: ?*Node = start;
while (current) |n| {
if (walk_end) |we| {
if (n == we) break;
}
if (n.is(Node.CData)) |cdata| {
if (!isCommentOrPI(cdata)) {
try writer.writeAll(cdata.getData());
}
}
current = nextInTreeOrder(n, root);
}
}
// Partial end: if end container is a different text node, write from start to offset
if (start_node != end_node) {
if (end_node.is(Node.CData)) |cdata| {
if (!isCommentOrPI(cdata)) {
const data = cdata.getData();
const e = @min(end_offset, data.len);
try writer.writeAll(data[0..e]);
}
}
// For elements, would need to iterate children
return;
}
}
// Complex case: different containers - would need proper tree walking
// For now, just return empty
fn isCommentOrPI(cdata: *Node.CData) bool {
return cdata.is(Node.CData.Comment) != null or cdata.is(Node.CData.ProcessingInstruction) != null;
}
fn nextInTreeOrder(node: *Node, root: *Node) ?*Node {
if (node.firstChild()) |child| return child;
return nextAfterSubtree(node, root);
}
fn nextAfterSubtree(node: *Node, root: *Node) ?*Node {
var current = node;
while (current != root) {
if (current.nextSibling()) |sibling| return sibling;
current = current.parentNode() orelse return null;
}
return null;
}
pub const JsApi = struct {

View File

@@ -32,13 +32,6 @@ const Screen = @This();
_proto: *EventTarget,
_orientation: ?*Orientation = null,
pub fn init(page: *Page) !*Screen {
return page._factory.eventTarget(Screen{
._proto = undefined,
._orientation = null,
});
}
pub fn asEventTarget(self: *Screen) *EventTarget {
return self._proto;
}

View File

@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const log = @import("../../log.zig");
const crypto = @import("../../crypto.zig");
const DOMException = @import("DOMException.zig");
const Page = @import("../Page.zig");
const js = @import("../js/js.zig");
@@ -218,6 +219,35 @@ pub fn verify(
};
}
pub fn digest(_: *const SubtleCrypto, algorithm: []const u8, data: js.TypedArray(u8), page: *Page) !js.Promise {
const local = page.js.local.?;
if (algorithm.len > 10) {
return local.rejectPromise(DOMException.fromError(error.NotSupported));
}
const normalized = std.ascii.lowerString(&page.buf, algorithm);
if (std.mem.eql(u8, normalized, "sha-1")) {
const Sha1 = std.crypto.hash.Sha1;
Sha1.hash(data.values, page.buf[0..Sha1.digest_length], .{});
return local.resolvePromise(js.ArrayBuffer{ .values = page.buf[0..Sha1.digest_length] });
}
if (std.mem.eql(u8, normalized, "sha-256")) {
const Sha256 = std.crypto.hash.sha2.Sha256;
Sha256.hash(data.values, page.buf[0..Sha256.digest_length], .{});
return local.resolvePromise(js.ArrayBuffer{ .values = page.buf[0..Sha256.digest_length] });
}
if (std.mem.eql(u8, normalized, "sha-384")) {
const Sha384 = std.crypto.hash.sha2.Sha384;
Sha384.hash(data.values, page.buf[0..Sha384.digest_length], .{});
return local.resolvePromise(js.ArrayBuffer{ .values = page.buf[0..Sha384.digest_length] });
}
if (std.mem.eql(u8, normalized, "sha-512")) {
const Sha512 = std.crypto.hash.sha2.Sha512;
Sha512.hash(data.values, page.buf[0..Sha512.digest_length], .{});
return local.resolvePromise(js.ArrayBuffer{ .values = page.buf[0..Sha512.digest_length] });
}
return local.rejectPromise(DOMException.fromError(error.NotSupported));
}
/// Returns the desired digest by its name.
fn findDigest(name: []const u8) error{Invalid}!*const crypto.EVP_MD {
if (std.mem.eql(u8, "SHA-256", name)) {
@@ -354,7 +384,7 @@ pub const CryptoKey = struct {
.object => |obj| obj.name,
};
// Find digest.
const digest = try findDigest(hash);
const d = try findDigest(hash);
// We need at least a single usage.
if (key_usages.len == 0) {
@@ -380,7 +410,7 @@ pub const CryptoKey = struct {
break :blk length / 8;
}
// Prefer block size of the hash function instead.
break :blk crypto.EVP_MD_block_size(digest);
break :blk crypto.EVP_MD_block_size(d);
};
const key = try page.arena.alloc(u8, block_size);
@@ -395,7 +425,7 @@ pub const CryptoKey = struct {
._extractable = extractable,
._usages = usages_mask,
._key = key,
._vary = .{ .digest = digest },
._vary = .{ .digest = d },
});
return .{ .key = crypto_key };
@@ -635,4 +665,5 @@ pub const JsApi = struct {
pub const sign = bridge.function(SubtleCrypto.sign, .{ .dom_exception = true });
pub const verify = bridge.function(SubtleCrypto.verify, .{ .dom_exception = true });
pub const deriveBits = bridge.function(SubtleCrypto.deriveBits, .{ .dom_exception = true });
pub const digest = bridge.function(SubtleCrypto.digest, .{ .dom_exception = true });
};

View File

@@ -25,12 +25,6 @@ const VisualViewport = @This();
_proto: *EventTarget,
pub fn init(page: *Page) !*VisualViewport {
return page._factory.eventTarget(VisualViewport{
._proto = undefined,
});
}
pub fn asEventTarget(self: *VisualViewport) *EventTarget {
return self._proto;
}

View File

@@ -60,7 +60,7 @@ _navigator: Navigator = .init,
_screen: *Screen,
_visual_viewport: *VisualViewport,
_performance: Performance,
_storage_bucket: *storage.Bucket,
_storage_bucket: storage.Bucket = .{},
_on_load: ?js.Function.Global = null,
_on_pageshow: ?js.Function.Global = null,
_on_popstate: ?js.Function.Global = null,
@@ -128,11 +128,11 @@ pub fn getPerformance(self: *Window) *Performance {
return &self._performance;
}
pub fn getLocalStorage(self: *const Window) *storage.Lookup {
pub fn getLocalStorage(self: *Window) *storage.Lookup {
return &self._storage_bucket.local;
}
pub fn getSessionStorage(self: *const Window) *storage.Lookup {
pub fn getSessionStorage(self: *Window) *storage.Lookup {
return &self._storage_bucket.session;
}
@@ -713,18 +713,19 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = .{ .internal = 1 } });
pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = .{ .internal = 2 } });
pub const top = bridge.accessor(Window.getWindow, null, .{});
pub const self = bridge.accessor(Window.getWindow, null, .{});
pub const window = bridge.accessor(Window.getWindow, null, .{});
pub const parent = bridge.accessor(Window.getWindow, null, .{});
pub const console = bridge.accessor(Window.getConsole, null, .{});
pub const navigator = bridge.accessor(Window.getNavigator, null, .{});
pub const screen = bridge.accessor(Window.getScreen, null, .{});
pub const visualViewport = bridge.accessor(Window.getVisualViewport, null, .{});
pub const performance = bridge.accessor(Window.getPerformance, null, .{});
pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{});
pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{});
pub const document = bridge.accessor(Window.getDocument, null, .{});
pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{});
pub const history = bridge.accessor(Window.getHistory, null, .{});
pub const navigation = bridge.accessor(Window.getNavigation, null, .{});
@@ -774,9 +775,25 @@ pub const JsApi = struct {
pub const innerWidth = bridge.property(1920, .{ .template = false });
pub const innerHeight = bridge.property(1080, .{ .template = false });
pub const devicePixelRatio = bridge.property(1, .{ .template = false });
// This should return a window-like object in specific conditions. Would be
// pretty complicated to properly support I think.
pub const opener = bridge.property(null, .{ .template = false });
pub const alert = bridge.function(struct {
fn alert(_: *const Window, _: ?[]const u8) void {}
}.alert, .{});
pub const confirm = bridge.function(struct {
fn confirm(_: *const Window, _: ?[]const u8) bool {
return false;
}
}.confirm, .{});
pub const prompt = bridge.function(struct {
fn prompt(_: *const Window, _: ?[]const u8, _: ?[]const u8) ?[]const u8 {
return null;
}
}.prompt, .{});
};
const testing = @import("../../testing.zig");

View File

@@ -37,6 +37,7 @@ pub const JsApi = struct {
pub const name = "Comment";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const constructor = bridge.constructor(Comment.init, .{});

View File

@@ -36,6 +36,7 @@ pub const JsApi = struct {
pub const name = "ProcessingInstruction";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const target = bridge.accessor(ProcessingInstruction.getTarget, null, .{});

View File

@@ -69,6 +69,7 @@ pub const JsApi = struct {
pub const name = "Text";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const constructor = bridge.constructor(Text.init, .{});

View File

@@ -30,18 +30,18 @@ _last_index: usize,
_last_length: ?u32,
_last_node: ?*std.DoublyLinkedList.Node,
_cached_version: usize,
_children: ?*Node.Children,
_node: *Node,
pub const KeyIterator = GenericIterator(Iterator, "0");
pub const ValueIterator = GenericIterator(Iterator, "1");
pub const EntryIterator = GenericIterator(Iterator, null);
pub fn init(children: ?*Node.Children, page: *Page) !*ChildNodes {
pub fn init(node: *Node, page: *Page) !*ChildNodes {
return page._factory.create(ChildNodes{
._node = node,
._last_index = 0,
._last_node = null,
._last_length = null,
._children = children,
._cached_version = page.version,
});
}
@@ -52,7 +52,7 @@ pub fn length(self: *ChildNodes, page: *Page) !u32 {
return cached_length;
}
}
const children = self._children orelse return 0;
const children = self._node._children orelse return 0;
// O(N)
const len = children.len();
@@ -65,13 +65,13 @@ pub fn getAtIndex(self: *ChildNodes, index: usize, page: *Page) !?*Node {
var current = self._last_index;
var node: ?*std.DoublyLinkedList.Node = null;
if (index <= current) {
if (index < current) {
current = 0;
node = self.first() orelse return null;
} else {
node = self._last_node orelse self.first() orelse return null;
}
defer self._last_index = current + 1;
defer self._last_index = current;
while (node) |n| {
if (index == current) {
@@ -86,7 +86,7 @@ pub fn getAtIndex(self: *ChildNodes, index: usize, page: *Page) !?*Node {
}
pub fn first(self: *const ChildNodes) ?*std.DoublyLinkedList.Node {
return &(self._children orelse return null).first()._child_link;
return &(self._node._children orelse return null).first()._child_link;
}
pub fn keys(self: *ChildNodes, page: *Page) !*KeyIterator {

View File

@@ -138,21 +138,59 @@ pub fn toggle(self: *DOMTokenList, token: []const u8, force: ?bool, page: *Page)
}
pub fn replace(self: *DOMTokenList, old_token: []const u8, new_token: []const u8, page: *Page) !bool {
try validateToken(old_token);
try validateToken(new_token);
// Validate in spec order: both empty first, then both whitespace
if (old_token.len == 0 or new_token.len == 0) {
return error.SyntaxError;
}
if (std.mem.indexOfAny(u8, old_token, WHITESPACE) != null) {
return error.InvalidCharacterError;
}
if (std.mem.indexOfAny(u8, new_token, WHITESPACE) != null) {
return error.InvalidCharacterError;
}
var lookup = try self.getTokens(page);
if (lookup.contains(new_token)) {
if (std.mem.eql(u8, new_token, old_token) == false) {
_ = lookup.orderedRemove(old_token);
try self.updateAttribute(lookup, page);
}
// Check if old_token exists
if (!lookup.contains(old_token)) {
return false;
}
// If replacing with the same token, still need to trigger mutation
if (std.mem.eql(u8, new_token, old_token)) {
try self.updateAttribute(lookup, page);
return true;
}
const key_ptr = lookup.getKeyPtr(old_token) orelse return false;
key_ptr.* = new_token;
try self.updateAttribute(lookup, page);
const allocator = page.call_arena;
// Build new token list preserving order but replacing old with new
var new_tokens = try std.ArrayList([]const u8).initCapacity(allocator, lookup.count());
var replaced_old = false;
for (lookup.keys()) |token| {
if (std.mem.eql(u8, token, old_token) and !replaced_old) {
new_tokens.appendAssumeCapacity(new_token);
replaced_old = true;
} else if (std.mem.eql(u8, token, old_token)) {
// Subsequent occurrences of old_token: skip (remove duplicates)
continue;
} else if (std.mem.eql(u8, token, new_token) and replaced_old) {
// Occurrence of new_token AFTER replacement: skip (remove duplicate)
continue;
} else {
// Any other token (including new_token before replacement): keep it
new_tokens.appendAssumeCapacity(token);
}
}
// Rebuild lookup
var new_lookup: Lookup = .empty;
try new_lookup.ensureTotalCapacity(allocator, new_tokens.items.len);
for (new_tokens.items) |token| {
try new_lookup.put(allocator, token, {});
}
try self.updateAttribute(new_lookup, page);
return true;
}
@@ -226,8 +264,16 @@ fn validateToken(token: []const u8) !void {
}
fn updateAttribute(self: *DOMTokenList, tokens: Lookup, page: *Page) !void {
const joined = try std.mem.join(page.call_arena, " ", tokens.keys());
try self._element.setAttribute(self._attribute_name, .wrap(joined), page);
if (tokens.count() > 0) {
const joined = try std.mem.join(page.call_arena, " ", tokens.keys());
return self._element.setAttribute(self._attribute_name, .wrap(joined), page);
}
// Only remove attribute if it didn't exist before (was null)
// If it existed (even as ""), set it to "" to preserve its existence
if (self._element.hasAttributeSafe(self._attribute_name)) {
try self._element.setAttribute(self._attribute_name, .wrap(""), page);
}
}
const Iterator = struct {
@@ -251,6 +297,7 @@ pub const JsApi = struct {
pub const name = "DOMTokenList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const length = bridge.accessor(DOMTokenList.length, null, .{});

View File

@@ -27,6 +27,7 @@ const NodeLive = @import("node_live.zig").NodeLive;
const Mode = enum {
tag,
tag_name,
tag_name_ns,
class_name,
all_elements,
child_elements,
@@ -42,6 +43,7 @@ const HTMLCollection = @This();
_data: union(Mode) {
tag: NodeLive(.tag),
tag_name: NodeLive(.tag_name),
tag_name_ns: NodeLive(.tag_name_ns),
class_name: NodeLive(.class_name),
all_elements: NodeLive(.all_elements),
child_elements: NodeLive(.child_elements),
@@ -76,6 +78,7 @@ pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator {
.tw = switch (self._data) {
.tag => |*impl| .{ .tag = impl._tw.clone() },
.tag_name => |*impl| .{ .tag_name = impl._tw.clone() },
.tag_name_ns => |*impl| .{ .tag_name_ns = impl._tw.clone() },
.class_name => |*impl| .{ .class_name = impl._tw.clone() },
.all_elements => |*impl| .{ .all_elements = impl._tw.clone() },
.child_elements => |*impl| .{ .child_elements = impl._tw.clone() },
@@ -94,6 +97,7 @@ pub const Iterator = GenericIterator(struct {
tw: union(Mode) {
tag: TreeWalker.FullExcludeSelf,
tag_name: TreeWalker.FullExcludeSelf,
tag_name_ns: TreeWalker.FullExcludeSelf,
class_name: TreeWalker.FullExcludeSelf,
all_elements: TreeWalker.FullExcludeSelf,
child_elements: TreeWalker.Children,
@@ -108,6 +112,7 @@ pub const Iterator = GenericIterator(struct {
return switch (self.list._data) {
.tag => |*impl| impl.nextTw(&self.tw.tag),
.tag_name => |*impl| impl.nextTw(&self.tw.tag_name),
.tag_name_ns => |*impl| impl.nextTw(&self.tw.tag_name_ns),
.class_name => |*impl| impl.nextTw(&self.tw.class_name),
.all_elements => |*impl| impl.nextTw(&self.tw.all_elements),
.child_elements => |*impl| impl.nextTw(&self.tw.child_elements),
@@ -127,6 +132,7 @@ pub const JsApi = struct {
pub const name = "HTMLCollection";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const length = bridge.accessor(HTMLCollection.length, null, .{});

View File

@@ -53,6 +53,10 @@ pub fn length(self: *NodeList, page: *Page) !u32 {
};
}
pub fn indexedGet(self: *NodeList, index: usize, page: *Page) !*Node {
return try self.getAtIndex(index, page) orelse return error.NotHandled;
}
pub fn getAtIndex(self: *NodeList, index: usize, page: *Page) !?*Node {
return switch (self.data) {
.child_nodes => |impl| impl.getAtIndex(index, page),
@@ -117,10 +121,11 @@ pub const JsApi = struct {
pub const name = "NodeList";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const enumerable = false;
};
pub const length = bridge.accessor(NodeList.length, null, .{});
pub const @"[]" = bridge.indexed(NodeList.getAtIndex, .{ .null_as_undefined = true });
pub const @"[]" = bridge.indexed(NodeList.indexedGet, .{ .null_as_undefined = true });
pub const item = bridge.function(NodeList.getAtIndex, .{});
pub const keys = bridge.function(NodeList.keys, .{});
pub const values = bridge.function(NodeList.values, .{});

View File

@@ -33,6 +33,7 @@ const Form = @import("../element/html/Form.zig");
const Mode = enum {
tag,
tag_name,
tag_name_ns,
class_name,
name,
all_elements,
@@ -44,9 +45,15 @@ const Mode = enum {
form,
};
pub const TagNameNsFilter = struct {
namespace: ?Element.Namespace, // null means wildcard "*"
local_name: String,
};
const Filters = union(Mode) {
tag: Element.Tag,
tag_name: String,
tag_name_ns: TagNameNsFilter,
class_name: [][]const u8,
name: []const u8,
all_elements,
@@ -83,7 +90,7 @@ const Filters = union(Mode) {
pub fn NodeLive(comptime mode: Mode) type {
const Filter = Filters.TypeOf(mode);
const TW = switch (mode) {
.tag, .tag_name, .class_name, .name, .all_elements, .links, .anchors, .form => TreeWalker.FullExcludeSelf,
.tag, .tag_name, .tag_name_ns, .class_name, .name, .all_elements, .links, .anchors, .form => TreeWalker.FullExcludeSelf,
.child_elements, .child_tag, .selected_options => TreeWalker.Children,
};
return struct {
@@ -222,6 +229,18 @@ pub fn NodeLive(comptime mode: Mode) type {
const element_tag = el.getTagNameLower();
return std.mem.eql(u8, element_tag, self._filter.str());
},
.tag_name_ns => {
const el = node.is(Element) orelse return false;
if (self._filter.namespace) |ns| {
if (el._namespace != ns) return false;
}
// ok, namespace matches, check local name
if (self._filter.local_name.eql(comptime .wrap("*"))) {
// wildcard, match-all
return true;
}
return self._filter.local_name.eqlSlice(el.getLocalName());
},
.class_name => {
if (self._filter.len == 0) {
return false;
@@ -328,6 +347,7 @@ pub fn NodeLive(comptime mode: Mode) type {
.name => return page._factory.create(NodeList{ .data = .{ .name = self } }),
.tag => HTMLCollection{ ._data = .{ .tag = self } },
.tag_name => HTMLCollection{ ._data = .{ .tag_name = self } },
.tag_name_ns => HTMLCollection{ ._data = .{ .tag_name_ns = self } },
.class_name => HTMLCollection{ ._data = .{ .class_name = self } },
.all_elements => HTMLCollection{ ._data = .{ .all_elements = self } },
.child_elements => HTMLCollection{ ._data = .{ .child_elements = self } },

View File

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

View File

@@ -37,6 +37,14 @@ pub fn asCSSStyleDeclaration(self: *CSSStyleProperties) *CSSStyleDeclaration {
return self._proto;
}
pub fn setNamed(self: *CSSStyleProperties, name: []const u8, value: []const u8, page: *Page) !void {
if (method_names.has(name)) {
return error.NotHandled;
}
const dash_case = camelCaseToDashCase(name, &page.buf);
try self._proto.setProperty(dash_case, value, null, page);
}
pub fn getNamed(self: *CSSStyleProperties, name: []const u8, page: *Page) ![]const u8 {
if (method_names.has(name)) {
return error.NotHandled;
@@ -108,6 +116,9 @@ fn isKnownCSSProperty(dash_case: []const u8) bool {
.{ "display", {} },
.{ "visibility", {} },
.{ "opacity", {} },
.{ "filter", {} },
.{ "transform", {} },
.{ "transition", {} },
.{ "position", {} },
.{ "top", {} },
.{ "bottom", {} },
@@ -201,5 +212,5 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const @"[]" = bridge.namedIndexed(CSSStyleProperties.getNamed, null, null, .{});
pub const @"[]" = bridge.namedIndexed(CSSStyleProperties.getNamed, CSSStyleProperties.setNamed, null, .{});
};

View File

@@ -1,6 +1,7 @@
const std = @import("std");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Element = @import("../Element.zig");
const CSSRuleList = @import("CSSRuleList.zig");
const CSSRule = @import("CSSRule.zig");
@@ -11,14 +12,18 @@ _title: []const u8 = "",
_disabled: bool = false,
_css_rules: ?*CSSRuleList = null,
_owner_rule: ?*CSSRule = null,
_owner_node: ?*Element = null,
pub fn init(page: *Page) !*CSSStyleSheet {
return page._factory.create(CSSStyleSheet{});
}
pub fn getOwnerNode(self: *const CSSStyleSheet) ?*CSSStyleSheet {
_ = self;
return null;
pub fn initWithOwner(owner: *Element, page: *Page) !*CSSStyleSheet {
return page._factory.create(CSSStyleSheet{ ._owner_node = owner });
}
pub fn getOwnerNode(self: *const CSSStyleSheet) ?*Element {
return self._owner_node;
}
pub fn getHref(self: *const CSSStyleSheet) ?[]const u8 {

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