198 Commits

Author SHA1 Message Date
Pierre Tachoire
6747182945 Add structuredClone() global function
Next.js hydration requires structuredClone(), which was not implemented.
Uses a JSON stringify/parse round-trip via V8 to deep-clone values,
covering all JSON-serializable types (objects, arrays, primitives).
2026-03-11 18:36:04 +01:00
Pierre Tachoire
7d835ef99d Merge pull request #1778 from lightpanda-io/wp/mrdimidium/libcurl-malloc
Use zig allocator for libcurl
2026-03-11 10:13:13 +01:00
Karl Seguin
0971df4dfc Merge pull request #1782 from lightpanda-io/silence_shutdown_error_on_non_linux
Don't log SocketNotConnected when shutting down listener on non-Linux
2026-03-11 16:39:15 +08:00
Halil Durak
9fb57fbac0 Merge pull request #1771 from lightpanda-io/nikneym/compile-function
Prefer `ScriptCompiler::CompileFunction` to compile attribute listeners
2026-03-11 11:38:16 +03:00
Karl Seguin
48ead90850 Don't log SocketNotConnected when shutting down listener on non-Linux
On BSD, a listening socket isn't considered connected, so this error is
expected. Maybe we shouldn't call shutdown at all, but I guess it's safer this
way.
2026-03-11 16:29:44 +08:00
Pierre Tachoire
cc88bb7feb Merge pull request #1777 from lightpanda-io/mcp-missing-lp-commands
mcp: add interactiveElements and structuredData tools
2026-03-11 09:11:48 +01:00
Karl Seguin
a725e2aa6a Merge pull request #1774 from egrs/range-chardata-mutations
update live ranges after CharacterData and DOM mutations
2026-03-11 16:04:41 +08:00
Pierre Tachoire
c8f8d79f45 Merge pull request #1775 from lightpanda-io/arena_blob
Use arena from ArenaPool for Blob (and File)
2026-03-11 08:35:27 +01:00
egrs
25c89c9940 Revert "remove ranges from live list on GC finalization"
This reverts commit 625d424199.
2026-03-11 08:04:53 +01:00
egrs
697a2834c2 Revert "fix CI: store list pointer on AbstractRange to avoid Page type mismatch"
This reverts commit 056b8bb536.
2026-03-11 08:04:51 +01:00
egrs
056b8bb536 fix CI: store list pointer on AbstractRange to avoid Page type mismatch
The bridge.finalizer resolves Page through its own module graph, which
can differ from Range.zig's import in release builds. Store a pointer
to the live_ranges list directly on AbstractRange so deinit can remove
without accessing Page fields.
2026-03-11 07:58:31 +01:00
egrs
625d424199 remove ranges from live list on GC finalization
Add a weak finalizer to Range that removes its linked list node from
Page._live_ranges when V8 garbage-collects the JS Range object. This
prevents the list from growing unboundedly and avoids iterating over
stale entries during mutation updates.
2026-03-11 07:27:39 +01:00
egrs
d2c55da6c9 address review: move per-range logic to AbstractRange, simplify collapsed check
Move the per-range update logic from Page into AbstractRange methods
(updateForCharacterDataReplace, updateForSplitText, updateForNodeInsertion,
updateForNodeRemoval). Page now just iterates the list and delegates.

Remove redundant start_container == end_container check in insertNode —
collapsed already implies same container.
2026-03-11 07:26:20 +01:00
Nikolay Govorov
c891eff664 Use zig allocator for libcurl 2026-03-11 03:34:27 +00:00
Adrià Arrufat
68564ca714 mcp: add interactiveElements and structuredData tools 2026-03-11 11:09:19 +09:00
Karl Seguin
ff26b0d5a4 Use arena from ArenaPool for Blob (and File) 2026-03-11 09:21:54 +08:00
Karl Seguin
487ee18358 Merge pull request #1742 from lightpanda-io/context_origins
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
Context origins
2026-03-11 08:54:53 +08:00
Karl Seguin
dc3d2e9790 Remove root context check from Env
This was only added [very briefly] when Env managed Origins, which it no longer
does.
2026-03-11 08:44:52 +08:00
Karl Seguin
f6d0e484b0 transfer finalizers on origin change 2026-03-11 08:44:52 +08:00
Karl Seguin
4cea9aba3c update v8 dep 2026-03-11 08:44:51 +08:00
Karl Seguin
7348a68c84 merge main 2026-03-11 08:44:51 +08:00
Karl Seguin
7d90c3f582 Move origin lookup to Session
With the last commit, this becomes the more logical place to hold this as it
ties into the Session's awareness of the root page's lifetime.
2026-03-11 08:44:51 +08:00
Karl Seguin
2a103fc94a Use Session as a container for cross-frame resources
The introduction of frames means that data is no longer tied to a specific Page
or Context. 255b9a91cc introduced Origins for
v8 values shared across frames of the same origin. The commit highlighted the
lifetime mismatched that we now have with data that can outlive 1 frame. A
specific issue with that commit was the finalizers were still Context-owned.
But like any other piece of data, that isn't right; aside from modules, nothing
should be context-owned.

This commit continues where the last left off and moves finalizers from Context
to Origin. This is done in a separate commit because it introduces significant
changes. Currently, finalizers take a *Page, but that's no longer correct. A
value created in one Page, can outlive the Page. We need another container. I
original thought to use Origin, but that isn't know to CDP/MCP. Instead, I
decide to enhance the Session.

Session is now the owner of the page.arena, the page.factory and the
page.arena_pool. Finalizers are given a *Session which they can use to release
their arena.
2026-03-11 08:44:49 +08:00
Karl Seguin
753391b7e2 Add origins safety cleanup when destroying the context for the root page 2026-03-11 08:43:41 +08:00
Karl Seguin
94ce5edd20 Frames on the same origin share v8 data
Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/153

In some ways this is an extension of
https://github.com/lightpanda-io/browser/pull/1635 but it has more implications
with respect to correctness.

A js.Context wraps a v8::Context. One of the important thing it adds is the
identity_map so that, given a Zig instance we always return the same v8::Object.

But imagine code running in a frame. This frame has its own Context, and thus
its own identity_map. What happens when that frame does:

```js
window.top.frame_loaded = true;
```

From Zig's point of view, `Window.getTop` will return the correct Zig instance.
It will return the *Window references by the "root" page. When that instance is
passed to the bridge, we'll look for the v8::Object in the Context's
`identity_map` but wont' find it. The mapping exists in the root context
`identity_map`, but not within this frame. So we create a new v8::Object and now
our 1 zig instance has N v8::Objects for every page/frame that tries to access
it.

This breaks cross-frame scripting which should work, at least to some degree,
even when frames are on the same origin.

This commit adds a `js.Origin` which contains the `identity_map`, along with our
other `v8::Global` storage. The `Env` now contains a `*js.Origin` lookup,
mapping an origin string (e.g. lightpanda.io:443) to an *Origin. When a Page's
URL is changed, we call `self.js.setOrigin(new_url)` which will then either get
or create an origin from the Env's origin lookup map.

js.Origin is reference counted so that it remains valid so long as at least 1
frame references them.

There's some special handling for null-origins (i.e. about:blank). At the root,
null origins get a distinct/isolated Origin. For a frame, the parent's origin
is used.

Above, we talked about `identity_map`, but a `js.Context` has 8 other fields
to track v8 values, e.g. `global_objects`, `global_functions`,
`global_values_temp`, etc. These all must be shared by frames on the same
origin. So all of these have also been moved to js.Origin. They've also been
merged so that we now have 3 fields: `identity_map`, `globals` and `temps`.

Finally, when the origin of a context is changed, we set the v8::Context's
SecurityToken (to that origin). This is a key part of how v8 allows cross-
context access.
2026-03-11 08:43:40 +08:00
Nikolay Govorov
3626f70d3e Merge pull request #1759 from lightpanda-io/wp/mrdimidum/net-poll-runtime
Network poll runtime
2026-03-10 23:38:07 +00:00
Nikolay Govorov
24cc24ed50 Fix Robots deinit 2026-03-10 23:28:40 +00:00
Karl Seguin
dd29ba4664 Merge pull request #1767 from egrs/css-value-normalization-gaps
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 / zig build release (push) Has been cancelled
wpt / build wpt runner (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
extend CSS value normalization to cover more properties
2026-03-11 06:28:34 +08:00
egrs
7927ad8fcf route appendData through replaceData for spec compliance
Per DOM spec, appendData(data) is defined as replaceData(length, 0, data).
While the range update would be a no-op (offset=length, count=0), routing
through replaceData ensures consistent code path and spec compliance.
2026-03-10 20:27:05 +01:00
egrs
d23453ce45 update live ranges after CharacterData and DOM mutations
Per DOM spec, all live ranges must have their boundary offsets updated
when CharacterData content changes (insertData, deleteData, replaceData,
splitText) or when nodes are inserted/removed from the tree.

Track live ranges via an intrusive linked list on Page. After each
mutation, iterate and adjust start/end offsets per the spec algorithms.

Also fix Range.deleteContents loop that read _end_offset on each
iteration (now decremented by the range update), and Range.insertNode
that double-incremented _end_offset for non-collapsed ranges.

Route textContent, nodeValue, and data setters through replaceData
so range updates fire consistently.

Fixes 9 WPT test files (all now 100%): Range-mutations-insertData,
deleteData, replaceData, splitText, appendChild, insertBefore,
removeChild, appendData, dataChange (~1330 new passing subtests).
2026-03-10 19:59:04 +01:00
Halil Durak
a22040efa9 update body.onload test 2026-03-10 19:16:35 +03:00
Halil Durak
ba3da32ce6 spread new stringToPersistedFunction 2026-03-10 19:16:20 +03:00
Halil Durak
9d2ba52160 adapt stringToPersistedFunction to compileFunction
This is just a thin wrapper around it now.
2026-03-10 19:15:53 +03:00
Halil Durak
e610506df4 Local: initial compileFunction 2026-03-10 18:14:35 +03:00
Pierre Tachoire
dd91d28bfa Merge pull request #1761 from lightpanda-io/wp/mrdimidium/c-tsan
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
Enable tsan for c libs
2026-03-10 15:33:33 +01:00
Pierre Tachoire
1ebf7460fe Merge pull request #1768 from lightpanda-io/inspector_cleanup
Call `resetContextGroup` on page removal
2026-03-10 15:32:47 +01:00
Pierre Tachoire
8c930e5c33 Merge pull request #1769 from lightpanda-io/form_action
Add Form.action getter/setter
2026-03-10 15:31:34 +01:00
egrs
4fb2f7474c remove incorrect entries from normalization maps
- Remove scale, contain-intrinsic-size, animation-range, text-box-edge
  from isTwoValueShorthand: these have asymmetric or 3-value semantics
  that make "X X" → "X" collapse incorrect.
- Remove line-height from isLengthProperty: bare 0 is the unitless
  number multiplier, not a length (Chrome serializes as "0" not "0px").
- Fix test: background-size "cover cover" is invalid CSS, use "auto auto".
2026-03-10 14:08:28 +01:00
Karl Seguin
5301f79989 Add Form.action getter/setter 2026-03-10 20:58:31 +08:00
egrs
6a7f7fdf15 extend CSS value normalization to cover more properties
Add missing properties to isLengthProperty (0→0px) and
isTwoValueShorthand (duplicate value collapse) maps based
on WPT test failures in css/css-sizing, css/css-align,
css/css-scroll-snap, css/css-logical, and others.

New length properties: scroll-margin/padding-*, column-width,
column-rule-width, grid-column/row-gap, outline, shape-margin,
offset-distance, translate, animation-range-*, block-step-size,
text-decoration-inset, and *-rule-*-inset (CSS Gaps).

New two-value shorthands: scroll-padding-block/inline,
scroll-snap-align, background-size, border-image-repeat,
mask-repeat/size, contain-intrinsic-size, scale, text-box-edge,
animation-range, grid-gap.
2026-03-10 13:53:27 +01:00
Karl Seguin
11fb5f990e Call resetContextGroup on page removal
Calling it here ensures that the inspector gets reset on internal page
navigation. We were seeing intermittent segfaults on a problematic WPT tests
(/encoding/legacy-mb-japanese/euc-jp/) which I believe this solves.

(The tests are still broken. Because we don't support form targets, they cause
the root page to reload in a tight cycle, causing a lot of context creation /
destruction, which I thin was the issue. This commit doesn't fix the broken test
but it hopefully fixes the crash).

Also, clear out the Inspector's default_context when the default context is
destroyed. (This was the first thing I did to try to fix the crash, it didn't
work, but I believe it's correct).
2026-03-10 20:50:58 +08:00
Adrià Arrufat
62f31ea24a Merge pull request #1765 from egrs/lp-get-structured-data
add LP.getStructuredData CDP command
2026-03-10 21:48:18 +09:00
egrs
f4ca5313e6 use std.mem.startsWith, group duplicate property keys into arrays
Address review feedback:
- replace custom startsWith helper with std.mem.startsWith
- writeProperties now groups repeated keys (e.g. multiple og:image)
  into JSON arrays; single-occurrence keys remain strings
- add test for duplicate key serialization
2026-03-10 13:18:25 +01:00
Karl Seguin
dfd90bd564 Merge pull request #1754 from lightpanda-io/css_value_normalization
Apply some normalization to CSS values
2026-03-10 17:36:27 +08:00
Pierre Tachoire
55508eb418 Merge pull request #1763 from lightpanda-io/has_direct_listener
Add a hasDirectListeners to EventManager
2026-03-10 10:28:39 +01:00
Pierre Tachoire
2a4fa4ed6f Merge pull request #1762 from lightpanda-io/xml_get_elements_by_tag_name
Node matching using tag name string comparison on non-HTML nodes
2026-03-10 10:27:47 +01:00
Pierre Tachoire
cf7c9f6372 Merge pull request #1760 from lightpanda-io/response_blob
Add new Response and Request methods
2026-03-10 10:26:16 +01:00
Pierre Tachoire
ec68c3207d Merge pull request #1764 from lightpanda-io/js_val_args
Better support for variadic js.Value parameter (e.g. console.log)
2026-03-10 10:16:27 +01:00
Pierre Tachoire
ecf140f3d6 Merge pull request #1766 from lightpanda-io/screenshot-size
cdp: reszie the screenshot to 1920x1080
2026-03-10 10:15:46 +01:00
Pierre Tachoire
13f73b7b87 Merge pull request #1750 from lightpanda-io/url_set_username_password
Add setters to URL.username and URL.password
2026-03-10 10:15:10 +01:00
Pierre Tachoire
12c5bcd24f cdp: reszie the screenshot to 1920x1080
To be consistent w/ layout size returned
2026-03-10 10:09:53 +01:00
egrs
74f0436ac7 merge main, resolve conflicts with getInteractiveElements 2026-03-10 09:25:12 +01:00
egrs
22d31b1527 add LP.getStructuredData CDP command 2026-03-10 09:19:51 +01:00
Karl Seguin
9f3bca771a Merge pull request #1755 from lightpanda-io/cdp-page-layout-metrics
cdp: add a dummy Page.getLayoutMetrics
2026-03-10 16:16:17 +08:00
Adrià Arrufat
4e16d90a81 Merge pull request #1757 from egrs/lp-get-interactive-elements
add LP.getInteractiveElements CDP command
2026-03-10 17:15:18 +09:00
Pierre Tachoire
d669d5c153 cdp: add a dummy Page.getLayoutMetrics 2026-03-10 08:54:48 +01:00
Karl Seguin
343d985e96 Better support for variadic js.Value parameter (e.g. console.log)
The bridge will prefer to map a Zig array to a JS Array, but in the case of
a []js.Value, it should be willing to map anything into it.
2026-03-10 15:40:18 +08:00
egrs
dc3958356d address review feedback
- TreeWalker.Full instead of FullExcludeSelf so querying a specific
  nodeId evaluates the root element itself
- resolve href to absolute URL via URL.resolve
- isDisabled checks ancestor <fieldset disabled> with legend exemption
- parameter order: allocator before *Page per convention
2026-03-10 08:13:01 +01:00
Karl Seguin
c4e85c3277 Add a hasDirectListeners to EventManager
Allows checking if a direct listener exists, if it doesn't, event creation can
be skipped.

I looked at a couple sites, the benefits of this is small.
Most sites don't seem to trigger that many direct dispatches and when they do,
they seem to have a listener 50-75% of the time.
2026-03-10 14:57:40 +08:00
Karl Seguin
89e46376dc Merge pull request #1752 from lightpanda-io/build-zig-fmt-check
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
build: add code formatting check
2026-03-10 14:04:28 +08:00
Karl Seguin
8eeb34dba8 Node matching using tag name string comparison on non-HTML nodes
NodeLive (used by, e.g. getElementsByTagName) needs to revert to the non-
optimized string-comparison for non-HTML tags.

This should help fix https://github.com/lightpanda-io/browser/issues/1214
2026-03-10 13:42:54 +08:00
Nikolay Govorov
7171305972 Enable tsan for c libs 2026-03-10 03:16:50 +00:00
Nikolay Govorov
2b0c223425 Some code-review fixes 2026-03-10 03:00:55 +00:00
Nikolay Govorov
8f960ab0f7 Uses posix pipe for shutdown network runtime 2026-03-10 03:00:53 +00:00
Nikolay Govorov
60350efa10 Only one listener in network.Runtime 2026-03-10 03:00:52 +00:00
Nikolay Govorov
687f577562 Move accept loop to common runtime 2026-03-10 03:00:50 +00:00
Nikolay Govorov
8e59ce9e9f Prepare global NetworkRuntime module 2026-03-10 03:00:47 +00:00
Karl Seguin
33d75354a2 Add new Response and Request methods
-Response.blob
-Response.clone
-Request.blob
-Request.text
-Request.json
-Request.arrayBuffer
-Request.bytes
-Request.clone
2026-03-10 09:05:06 +08:00
Karl Seguin
0e4a65efb7 Merge pull request #1758 from lightpanda-io/http-auth-challenge
http: handle auth challenge for non-proxy auth
2026-03-10 06:39:14 +08:00
Karl Seguin
b88134cf04 Merge pull request #1756 from lightpanda-io/cdp-screenshot
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 / zig build release (push) Has been cancelled
wpt / build wpt runner (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
cdp: add dummy page.captureScreenshot
2026-03-10 06:37:33 +08:00
Karl Seguin
2aaa212dbc Merge pull request #1753 from lightpanda-io/document_applets
document.applets should always return an empty collection
2026-03-10 06:35:23 +08:00
Karl Seguin
1e37990938 Merge pull request #1741 from lightpanda-io/DOMParser_invalid_xml
Throw exception, as expected, on empty input to DOMParser.parseFromSt…
2026-03-10 06:32:48 +08:00
egrs
a417c73bf7 add LP.getInteractiveElements CDP command
Returns a structured list of all interactive elements on a page:
buttons, links, inputs, ARIA widgets, contenteditable regions, and
elements with event listeners. Includes accessible names, roles,
listener types, and key attributes.

Event listener introspection (both addEventListener and inline
handlers) is unique to LP — no other browser exposes this to
automation code.
2026-03-09 19:46:12 +01:00
Pierre Tachoire
37c34351ee http: handle auth challenge for non-proxy auth 2026-03-09 19:23:36 +01:00
Pierre Tachoire
8672232ee2 cdp: add dummy page.captureScreenshot 2026-03-09 17:38:57 +01:00
Karl Seguin
3ad10ff8d0 Add support for normalization anchor-size css value
vibed this. Seems esoteric, but it helps over 1000 WPT cases pass in
/css/css-anchor-position/anchor-size-parse-valid.html
2026-03-09 18:25:01 +08:00
Karl Seguin
183643547b document.applets should always return an empty collection
Add a new .empty mode to HTMLCollection.

Fixes WPT /shadow-dom/leaktests/html-collection.html
2026-03-09 18:06:22 +08:00
Adrià Arrufat
5568340b9a build: add code formatting check 2026-03-09 18:48:38 +09:00
Karl Seguin
1399bd3065 Apply some normalization to CSS values
"10px 10px" should present as "10px".  A length of "0" should present as "0px"

Fixes a handful of WPT tests.
2026-03-09 17:47:59 +08:00
Karl Seguin
9172e16e80 Merge pull request #1751 from lightpanda-io/zig-fmt-face
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
zig fmt
2026-03-09 17:34:17 +08:00
Adrià Arrufat
3e5f602396 zig fmt 2026-03-09 18:25:09 +09:00
Karl Seguin
379a3f27b8 Merge pull request #1744 from egrs/add-range-client-rect
add Range.getBoundingClientRect and getClientRects
2026-03-09 17:17:17 +08:00
Karl Seguin
ecec932a47 Add setters to URL.username and URL.password
Also, preserve port when setting host.
2026-03-09 17:13:12 +08:00
egrs
e239f69f69 delegate Range rect methods to container element
Instead of always returning zeros, delegate getBoundingClientRect and
getClientRects to the common ancestor container element. Return zeros
only when the range is collapsed or has no element ancestor.
2026-03-09 10:09:11 +01:00
Karl Seguin
034b089433 Merge pull request #1749 from lightpanda-io/empty_is_and_where_pseudoselector
Empty :is() and :where() pseudoselectors are valid (and return nothing)
2026-03-09 16:55:43 +08:00
Karl Seguin
c0db96482c Merge pull request #1748 from lightpanda-io/font_face_optimization
Optimize FontFace
2026-03-09 16:55:28 +08:00
Karl Seguin
ffa8fa0a6f Merge pull request #1745 from lightpanda-io/renavigate_memory_leak
Fix leak introduced in inner navigation refactoring
2026-03-09 16:55:12 +08:00
Karl Seguin
7e1d459a2d Merge pull request #1746 from egrs/fix-module-relative-imports
fix module re-import when previous compilation failed
2026-03-09 16:44:43 +08:00
Karl Seguin
71c4fce87f Empty :is() and :where() pseudoselectors are valid (and return nothing) 2026-03-09 16:39:44 +08:00
Karl Seguin
e91da78ebb Optimize FontFace
Follow up to https://github.com/lightpanda-io/browser/pull/1743

Allow eager cleanup with finalizer. User properties for (what are currently)
constants.
2026-03-09 16:08:17 +08:00
egrs
8adad6fa61 fix module re-import when previous compilation failed
When a module's compilation fails after its imported_modules entry has
been consumed by waitForImport, sibling modules that import the same
dependency would get UnknownModule errors. Fix by re-preloading modules
whose cache entry exists but has no compiled module.
2026-03-09 08:58:07 +01:00
Karl Seguin
b47004bb7c Merge pull request #1743 from egrs/add-fontface-constructor
add FontFace constructor and FontFaceSet.add()
2026-03-09 15:57:59 +08:00
Karl Seguin
08a7fb4de0 Fix leak introduced in inner navigation refactoring
A inner-navigate event can override an existing pending queued navigation. When
it does, the previously queued navigation has to be cleaned up. We were doing
this, but it must have been stripped out when navigation was refactored to work
with frames.
2026-03-09 15:51:26 +08:00
Karl Seguin
c17a9b11cc Merge pull request #1740 from egrs/fix-dynamic-inline-scripts
execute dynamically inserted inline script elements
2026-03-09 15:43:28 +08:00
egrs
245a92a644 use node.firstChild() directly per review feedback
node is already available in scope — no need to traverse back through
script.asConstElement().asConstNode().
2026-03-09 08:31:54 +01:00
Pierre Tachoire
6b313946fe Merge pull request #1739 from lightpanda-io/wpt-procs
wpt: use a pool of browser to run tests
2026-03-09 08:29:16 +01:00
egrs
4586fb1d13 add Range.getBoundingClientRect and getClientRects
headless stubs returning zero-valued DOMRect / empty list per CSSOM
View spec. fixes "getBoundingClientRect is not a function" errors on
sites where layout code calls this on Range objects (e.g. airbnb).
2026-03-09 08:23:19 +01:00
egrs
aa051434cb add FontFace constructor and FontFaceSet.add()
headless stub for the FontFace API — constructor stores family/source,
status is always "loaded", load() resolves immediately. enables sites
that use new FontFace() for programmatic font loading (e.g. boursorama).
2026-03-09 08:14:41 +01:00
Karl Seguin
f3e1204fa1 Throw exception, as expected, on empty input to DOMParser.parseFromString
https://github.com/lightpanda-io/browser/issues/1738
2026-03-09 13:46:36 +08:00
Pierre Tachoire
1cb5d26344 wpt: use a pool of browser to run tests 2026-03-08 20:55:15 +01:00
egrs
ec9a2d8155 execute dynamically inserted inline script elements
Scripts created via createElement('script') with inline content (textContent,
text, or innerHTML) and inserted into the DOM now execute per the HTML spec.
Previously all dynamically inserted scripts without a src attribute were
skipped, breaking most JS framework hydration patterns.
2026-03-08 16:17:52 +01:00
Karl Seguin
0227afffc8 Merge pull request #1735 from egrs/fix-missing-dom-exception-flags
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 / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
add missing dom_exception flags to bridge declarations
2026-03-08 16:36:56 +08:00
Karl Seguin
6a421a1d96 Merge pull request #1734 from lightpanda-io/mcp_safer_navigate
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix page re-navigate
2026-03-08 07:17:39 +08:00
egrs
4f55a0f1d0 add missing dom_exception flags to bridge declarations
atob, Performance.measure, and Navigation methods (back, forward,
navigate, traverseTo, updateCurrentEntry) return DOMException errors
but were missing the dom_exception flag, causing them to throw generic
Error objects instead of proper DOMException instances in JavaScript.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 14:34:28 +01:00
Karl Seguin
3de55899fa fix test 2026-03-07 11:04:22 +08:00
Karl Seguin
ae4ad713ec Fix page re-navigate
It isn't safe/correct to call `navigate` on the same page multiple times. A page
is meant to have 1 navigate call. The caller should either remove the page
and create a new one, or call Session.replacePage.

This commit removes the *Page from the MCP Server and instead interacts with
the session to create or remove+create the page as needed, and lets the Session
own the *Page.

It also adds a bit of defensiveness around parameter parsing, e.g. calling
{"method": "tools/call"} (without an id) now errors instead of crashing.
2026-03-07 10:19:37 +08:00
Karl Seguin
21313adf9c Merge pull request #1728 from lightpanda-io/about_blank
Some checks failed
e2e-test / zig build release (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
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (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
Optimize about:blank loading in general and for frames specifically
2026-03-06 23:38:11 +08:00
Karl Seguin
9c1293ca45 Merge pull request #1729 from lightpanda-io/target_navigation
Add target-aware(ish) navigation
2026-03-06 23:38:01 +08:00
Karl Seguin
1cb1e6b680 Merge pull request #1720 from lightpanda-io/frame_scheduled_navigation
Improve frame sub-navigation
2026-03-06 23:37:49 +08:00
Karl Seguin
ed6ddeaa4c Merge pull request #1732 from lightpanda-io/custom_element_clone_take_2
Fix cloning custom element with constructor which attaches the element
2026-03-06 23:37:29 +08:00
Karl Seguin
de08a89e6b Merge pull request #1726 from lightpanda-io/fix_keyboard_event_leak
Release KeyboardEvent if it isn't used
2026-03-06 23:37:15 +08:00
Karl Seguin
dd42ef1920 Merge pull request #1727 from lightpanda-io/halt_tests_on_arena_leak
Halt tests (@panic) on ArenaLeak or double-free
2026-03-06 23:35:33 +08:00
Pierre Tachoire
dd192be689 Merge pull request #1730 from lightpanda-io/wpt-concurrency
wpt: increase concurrency
2026-03-06 16:26:30 +01:00
Pierre Tachoire
52250ed10e wpt: increase concurrency 2026-03-06 15:59:28 +01:00
Karl Seguin
4a26cd8d68 Halt tests (@panic) on ArenaLeak or double-free
These are too hard to see during a full test run.
2026-03-06 20:41:57 +08:00
Karl Seguin
2ca972c3c8 Merge pull request #1731 from lightpanda-io/revert-rs-arena
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
Revert pool arena usage w/ ReadableStream
2026-03-06 19:28:44 +08:00
Karl Seguin
74c0d55a6c Fix cloning custom element with constructor which attaches the element
This is a follow up to ca0f77bdee that applies
the same fix to all places where cloneNode is used and then the cloned element
is inserted. A helper was added more of a means of documentation than to DRY
up the code.
2026-03-06 17:38:16 +08:00
Pierre Tachoire
3271e1464e Revert pool arena usage w/ ReadableStream
Revert "update ref counting for new ReadableStream usages"
This reverts commit c64500dd85.

Revert "add reference counting for ReadableStream"
This reverts commit 812ad3f49e.

Revert "use a pool arena with ReadableStream"
This reverts commit 8e8a1a7541.
2026-03-06 10:21:36 +01:00
Karl Seguin
cabd62b48f Optimize about:blank loading in general and for frames specifically
Instead of going through the parser, just create / append the 3 elements.

iframe without a src automatically loads about:blank. This is important, because
the following is valid:

```js
const iframe = document.createElement('iframe');
document.documentElement.appendChild(iframe);

// documentElement should exist and should be the HTML of the blank page.
iframe.contentDocument.documentElement.appendChild(...);
```

Builds on top of https://github.com/lightpanda-io/browser/pull/1720
2026-03-06 17:15:43 +08:00
Karl Seguin
58c2355c8b Merge pull request #1725 from egrs/fix-mcp-test-hang-aarch64
initialize all App fields after allocator.create
2026-03-06 17:11:40 +08:00
Karl Seguin
bfe2065b9f Add target-aware(ish) navigation
All inner navigations have an originator and a target. Consider this:

```js
aframe.contentDocument.querySelector('#link').click();
```

The originator is the context in which this JavaScript is called, the target is
`aframe. Importantly, relative URLs are resolved based on the originator. This
commit adds that.

This is only a first step, there are other aspect to this relationship that
isn't addressed yet, like differences in behavior if the originator and target
are on different origins, and specific target targetting via the things like
the "target" attribute. What this commit does though is address the normal /
common case.

It builds on top of https://github.com/lightpanda-io/browser/pull/1720
2026-03-06 16:57:28 +08:00
egrs
9332b1355e initialize all App fields after allocator.create
Same pattern as 3dea554e (mcp/Server.zig): allocator.create returns
undefined memory, and struct field defaults (shutdown: bool = false)
are not applied when fields are set individually. Use self.* = .{...}
to ensure all fields including defaults are initialized.
2026-03-06 09:37:55 +01:00
Karl Seguin
679e703754 Release KeyboardEvent if it isn't used 2026-03-06 09:12:58 +08:00
Karl Seguin
7322f90af4 Merge pull request #1722 from lightpanda-io/fetch_wait_for_background
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
Run the message loop more!
2026-03-06 08:22:41 +08:00
Karl Seguin
e869df98c9 Merge pull request #1723 from lightpanda-io/cleanup-treewalker-helpers
TreeWalker: remove unused methods
2026-03-06 08:19:03 +08:00
Pierre Tachoire
e499d36126 Merge pull request #1724 from lightpanda-io/dockerfile-remove-submodules
Some checks failed
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (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
Dockerfile: remove git submodule initialization
2026-03-05 15:19:42 +01:00
Adrià Arrufat
cac66d7fad Dockerfile: remove git submodule initialization 2026-03-05 22:18:38 +09:00
Adrià Arrufat
320aaf0e33 TreeWalker: remove unused methods
They were introduced in:

- https://github.com/lightpanda-io/browser/pull/1718
2026-03-05 21:51:22 +09:00
Karl Seguin
178a175e99 Merge pull request #1698 from lightpanda-io/readablestream-pool-arena
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
use a pool arena with ReadableStream
2026-03-05 18:57:06 +08:00
Karl Seguin
5fdf1cb2d1 Run the message loop more!
In https://github.com/lightpanda-io/browser/pull/1651 we started to run the
message loop a lot more. One specific case we added for `fetch` was when there
were no scheduled tasks or HTTP, but background tasks, we'd wait for them to
complete.

One case we missed though is if WE do have a schedule tasks, but it's too far
into the future. In that case, we would just exit. This now adds the same logic
for checking and waiting for any background tasks in that case.
2026-03-05 18:51:34 +08:00
Pierre Tachoire
c64500dd85 update ref counting for new ReadableStream usages 2026-03-05 11:47:48 +01:00
Pierre Tachoire
812ad3f49e add reference counting for ReadableStream 2026-03-05 11:47:48 +01:00
Pierre Tachoire
8e8a1a7541 use a pool arena with ReadableStream 2026-03-05 11:47:47 +01:00
Karl Seguin
4863b3df6e Merge pull request #1721 from lightpanda-io/fix_mcp_unintialized_memory
Ensure that mcp.Server is correctly initialized
2026-03-05 17:11:57 +08:00
Karl Seguin
768c3a533b Simplify navigation logic.
Must of the complexity in the previous commit had to do with the fact that
about:blank is processed synchronously, meaning that we could process a
scheduled navigation -> page.navigate -> scheduled navigation:

```
let iframe = document.createElement('iframe');
iframe.addEventListner('load', () => {
  iframe.src = "about:blank";
});
```

This is an infinite loop which is going to be a problem no mater what, but there
are different degrees of problems this can cause, e.g. looping forever vs use-
after-free or other undefined behavior.

The new approach does 2 passes through scheduled navigations, first processing
"asynchronous" navigation (anything not "about:blank"), then processing
synchronous navigation ("about:blank"). The main advantage is that if the
synchronous navigation causes more synchronous navigation, it won't be
processed until the next tick. PLUS, we can detect about:blank that loads
about:blank and stop it (which might not be to spec, but seems right to do
nonetheless). This 2-pass approach removes the need for a couple of checks and
makes everything else simpler.
2026-03-05 17:06:23 +08:00
Karl Seguin
3dea554e9e Ensure that mcp.Server is correctly initialized
It relies on default field values, e.g. for mutex: std.Thread.Mutex = .{}, but
doesn't initialize the structure, just the pointer on the heap resulting in a
crash.
2026-03-05 16:32:25 +08:00
Karl Seguin
16d4f6e4e1 Merge pull request #1718 from lightpanda-io/enhance-treewalker
Enhance TreeWalker
2026-03-05 15:28:04 +08:00
Karl Seguin
9c7ecf221e Improve frame sub-navigation
This makes frame sub-navigation "work" for all page navigations (click, form
submit, location.top...) as well as setting the iframe.src.

Fixes at least 2 WPT crashes.

BUT, the implementation still isn't 100% correct, with two known issues:

1. Navigation currently happens in the context where it's called, not the
   context of the frame. So if Page1 accesses Frame1 and causes it to navigate,
   e.g. f1.contentDocument.querySelector('#link').click(), it's Page1 that will
   be navigated, since the JS is being executed in the Page1 context.
   This should be relatively easy to fix.

2. There are particularly complicated cases in WPT where a frame is navigated
   inside of its own load, creating an endless loop. There's some partial
   support for this as-is, but it doesn't work correctly and it currently is
   defensive and likely will not continue to navigate. This is particularly true
   when sub-navigation is done to about:blank within the frame's on load event.
   (Which is probably not a real concern, but an issue for some WPT tests)

Although it shares a lot with the original navigation code, there are many more
edge cases here, possibly due to being developed along side WPT tests. The
source of most of the complexity is the synchronous handling of "about:blank"
in page.navigate, which can result in a scheduled navigation synchronously
causing more scheduled navigation. (Specifically because
`self.documentIsComplete();` is called from page.navigate in that case). It
might be worth seeing if something can be done about that, to simplify this new
code (removing the double queue, removing the flag, simplifying pre-existing
schedule checks ,...)
2026-03-05 15:09:39 +08:00
Adrià Arrufat
26db481d46 markdown: refactor content discovery to use TreeWalker 2026-03-05 14:36:15 +09:00
Adrià Arrufat
3256a57230 TreeWalker: add sibling navigation and skipChildren 2026-03-05 14:29:42 +09:00
Karl Seguin
cbc30587ff Merge pull request #1717 from lightpanda-io/improve-markdown-links
Improve markdown links
2026-03-05 13:09:16 +08:00
Adrià Arrufat
a27de38c03 markdown: encode resolved URLs in links and images 2026-03-05 13:57:42 +09:00
Adrià Arrufat
e2f1609116 markdown: use aria-label or title for empty links 2026-03-05 11:27:51 +09:00
Adrià Arrufat
ea66a91a95 markdown: resolve absolute URLs and skip empty links 2026-03-05 10:48:18 +09:00
Pierre Tachoire
0d87c352b2 Merge pull request #1716 from lightpanda-io/wpt-again
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 / zig build release (push) Has been cancelled
wpt / build wpt runner (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: for wpt run with --concurrency=3
2026-03-04 18:04:07 +01:00
Pierre Tachoire
918f6ce0e6 ci: for wpt run with --concurrency=3 2026-03-04 15:54:48 +01:00
Karl Seguin
6c5efe6ce0 Merge pull request #1715 from lightpanda-io/cdp-frame-navigate
cdp: don't dispatch executionContextsCleared on frame navigation
2026-03-04 22:02:30 +08:00
Karl Seguin
f0be6675e7 Merge pull request #1714 from lightpanda-io/fix-req-id
cdp: fix req id resolver, they are REQ- not RID-
2026-03-04 21:59:04 +08:00
Pierre Tachoire
6a8174a15c cdp: don't dispatch executionContextsCleared on frame navigation 2026-03-04 14:45:21 +01:00
Pierre Tachoire
40c3f1b618 cdp: fix req id resolver, they are REQ- not RID- 2026-03-04 13:00:16 +01:00
Pierre Tachoire
6dd2dac049 Merge pull request #1704 from lightpanda-io/non-ascii-css-key
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
css: fix crash in consumeName() on UTF-8 multibyte sequences
2026-03-04 12:35:14 +01:00
Karl Seguin
b39bbb557f Merge pull request #1713 from lightpanda-io/dynamic_module_instantiation
Force dynamic module instantiation if not already instantiated
2026-03-04 16:27:06 +08:00
Karl Seguin
f7682cba67 Force dynamic module instantiation if not already instantiated
I couldn't come up with a reproducible case where this was needed, but we're
seeing some crash reports indicate that this is happening.
2026-03-04 16:12:11 +08:00
Pierre Tachoire
f94c07160a Merge pull request #1712 from lightpanda-io/css-selector-quote
Handle commas inside quoted attributes
2026-03-04 09:00:01 +01:00
Karl Seguin
bbe6692580 Merge pull request #1711 from lightpanda-io/iframe_about_blank
iframe handling for src = "about:blank"
2026-03-04 15:56:26 +08:00
Karl Seguin
9266a1c4d9 Merge pull request #1709 from lightpanda-io/expand_event_dispatch_handle_scope
Use a single HandleScope for event dispatch
2026-03-04 15:56:13 +08:00
Pierre Tachoire
220d80f05f Handle commas inside quoted attributes
In CSS selector, commas inside quoted attribute are not selector separators, but part of
the attribute value.
2026-03-04 08:49:33 +01:00
Karl Seguin
9144c909dd Merge pull request #1710 from lightpanda-io/custom_element_clone
Support for clone custom elements that attach them self in their cons…
2026-03-04 15:47:39 +08:00
Karl Seguin
7981fcec84 iframe handling for src = "about:blank"
Don't try to resolve an iframe's source if it's about:blank

Extend the page's handling of about:blank to render an empty document
2026-03-04 15:43:07 +08:00
Pierre Tachoire
71264c56fc Merge pull request #1696 from lightpanda-io/textencoder-stream
Add TextEncoderStream and TextDecoderStream implementation
2026-03-04 07:58:56 +01:00
Karl Seguin
ca0f77bdee Support for clone custom elements that attach them self in their constructor
When we createElement, we assume the element is detached. This is usually true
except for Custom Elements where the constructor can do anything, including
connecting the element. This broken assumption results in cloneNode crashing.
2026-03-04 14:54:34 +08:00
Karl Seguin
fc8b1b8549 Use a single HandleScope for event dispatch
https://github.com/lightpanda-io/browser/pull/1690 narrowed the lifetime of
HandleScopes to once per listener. I think that was just an accident of
refactoring, and not some intentional choice.

The narrower HandleScope lifetime makes it so that when we do run microtask
queue at the end of event dispatching, some locals in the queue may not longer
be valid.

HS1
  HS2
    queueMicrotask(func)
  runMicrotask

In the above flow, `func` is only valid while HS2 is alive, so when we run
the microtask queue in HS1, it is no longer valid.
2026-03-04 11:43:09 +08:00
Karl Seguin
bc8c44f62f Merge pull request #1707 from lightpanda-io/nikneym/details
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 `HTMLDetailsElement`
2026-03-04 07:44:11 +08:00
Karl Seguin
01fab5c92a Merge pull request #1706 from lightpanda-io/cdp-attach-to-browser
cdp: fix send CDP raw command with Playwright
2026-03-04 07:40:05 +08:00
Karl Seguin
1c07d786a0 Merge pull request #1705 from lightpanda-io/nikneym/track
` Track`: implement kind and constants
2026-03-04 07:34:12 +08:00
Karl Seguin
6f0cd87d1c Merge pull request #1703 from lightpanda-io/client_and_script_manager
Fix a few issues in Client
2026-03-04 07:32:14 +08:00
Karl Seguin
e44308cba2 Merge pull request #1695 from lightpanda-io/iframe_src_nav
Iframe src nav
2026-03-04 07:27:23 +08:00
Karl Seguin
50245c5157 Merge pull request #1667 from lightpanda-io/terminate_isolate
On Client.stop, terminate the isolate
2026-03-04 07:27:10 +08:00
Pierre Tachoire
9ca5188e12 cdp: set consistent target's default
with about:blank for url and empty title.
2026-03-03 17:24:08 +01:00
Pierre Tachoire
56cc881ac0 Fcdp: fix attachtToTarget and attachToBrowserTarget resp 2026-03-03 15:01:53 +01:00
Halil Durak
50896bdc9d HTMLDetailsElement: add tests 2026-03-03 15:12:12 +03:00
Halil Durak
8dd4567828 HTMLDetailsElement: implement HTMLDetailsElement 2026-03-03 15:12:02 +03:00
Pierre Tachoire
06ef6d3e6a cdp: attachToTarget must add the session id 2026-03-03 12:58:00 +01:00
Pierre Tachoire
14b58e8062 add target.attachToBrowserTarget 2026-03-03 12:58:00 +01:00
Pierre Tachoire
eee232c12c cdp: allow multiple calls to attachToTarget
Playwright, when creating a new CDPSession, sends an
attachToBrowserTarget followed by another attachToTarget to re-attach
itself to the existing target.

see playwright/axtree.js from demo/ repository.
2026-03-03 12:58:00 +01:00
Halil Durak
febe321aef Track: add tests 2026-03-03 14:41:05 +03:00
Halil Durak
28777ac717 Track: implement kind and constants 2026-03-03 14:40:53 +03:00
Pierre Tachoire
13b008b56c css: fix crash in consumeName() on UTF-8 multibyte sequences
advance() asserts that each byte it steps over is either an ASCII byte
or a UTF-8 sequence leader, never a continuation byte (0x80–0xBF).
consumeName() was calling advance(1) for all non-ASCII bytes
('\x80'...'\xFF'), processing multi-byte sequences one byte at a time.
For a two-byte sequence like é (0xC3 0xA9), the second iteration landed
on the continuation byte 0xA9 and triggered the assertion, crashing the
browser in Debug mode.

Fix: replace advance(1) with consumeChar() for all non-ASCII bytes.
consumeChar() reads the lead byte, derives the sequence length via
utf8ByteSequenceLength, and advances the full code point in one step,
so the position never rests on a continuation byte.

Observed on saintcyrlecole.caliceo.com, whose root element carries an
inline style with custom property names containing French accented
characters (--color-store-bulles-été-fg, etc.). The crash aborted JS
execution before the Angular app could render any dynamic content.
2026-03-03 11:13:30 +01:00
Karl Seguin
523efbd85a Fix a few issues in Client
Most significantly, if removing from the multi fails, the connection
is added to a "dirty" list for the removal to be retried later. Looking at
the curl source code, remove fails on a recursive call, and we've struggled with
recursive calls before, so I _think_ this might be happening (it fails in other
cases, but I suspect if it _is_ happening, it's for this reason). The retry
happens _after_ `perform`, so it cannot fail for due to recursiveness. If it
fails at this point, we @panic. This is harsh, but it isn't easily recoverable
and before putting effort into it, I'd like to know that it's actually happening.

Fix potential use of undefined when a 401-407 request is received, but no
'WWW-Authenticate' or 'Proxy-Authenticate' header is received.

Don't call `curl_multi_remove_handle` on an easy that hasn't been added yet do
to error. Specifically, if `makeRequest` fails during setup, transfer_conn is
nulled so that `transfer.deinit()` doesn't try to remove the connection. And the
conn is removed from the `in_use` queue and made `available` again.

On Abort, if getting the private fails (extremely unlikely), we now still try
to remove the connection from the multi.

Added a few more fields to the famous "ScriptManager.Header recall" assertion.
2026-03-03 18:02:06 +08:00
Pierre Tachoire
fcacc8bfc6 remove the isString type check into TransformStream write 2026-03-03 09:40:32 +01:00
Pierre Tachoire
252b3c3bf6 Ignore BOM only when the option is set on TextDecoderStream 2026-03-03 09:04:41 +01:00
Pierre Tachoire
24221748e1 Merge pull request #1699 from lightpanda-io/textencoder-stream-enhancements
Textencoder stream enhancements
2026-03-03 08:12:07 +01:00
Karl Seguin
141ae053db leverage JS bridge's type mapping 2026-03-03 11:43:13 +08:00
Karl Seguin
10ec4ff814 Create Zig wrapper generator for js.Function creation
This allows us to leverage the Caller.Function.call method, which does type
mapping, caching, etc... and allows the Zig function callback to be written like
any other Zig WebAPI function.
2026-03-03 11:41:00 +08:00
Pierre Tachoire
d2da0b7c0e remove useless _page field from WritableStream* 2026-03-02 18:09:46 +01:00
Pierre Tachoire
7d0548406e Move V8 pipe callback helpers into js/ layer
ReadableStream.zig was the only webapi file importing v8 directly.
Extract the repeated newFunctionWithData / callback boilerplate into
js/Local (newFunctionWithData) and js/Caller (initFromHandle,
FunctionCallbackInfo.getData), and update ReadableStream and Context
to use them.
2026-03-02 17:33:56 +01:00
Pierre Tachoire
c121dbbd67 Add desiredSize accessor to WritableStreamDefaultWriter
Returns 1 when writable (default high water mark), 0 when closed,
and null when errored, matching the spec behavior for streams
without a custom queuing strategy.
2026-03-02 14:41:03 +01:00
Pierre Tachoire
c1c0a7d494 Skip enqueue of empty chunks in TextDecoderStream
After BOM stripping or when receiving an empty Uint8Array, the
decoded input can be zero-length. Per spec, empty chunks should
produce no output rather than enqueuing an empty string.
2026-03-02 14:30:39 +01:00
Pierre Tachoire
0749f60702 Preserve chunk value types through ReadableStream enqueue/read
When JS called controller.enqueue(42), the value was coerced to the
string "42" because Chunk only had uint8array and string variants.
Add a js_value variant that persists the raw JS value handle, and
expose enqueueValue(js.Value) as the JS-facing enqueue method so
numbers, booleans, and objects round-trip with their original types.
2026-03-02 14:24:49 +01:00
Pierre Tachoire
ca0ef18bdf Implement async piping for ReadableStream.pipeThrough/pipeTo
Replace synchronous queue-draining approach with async promise-based
piping using V8's thenAndCatch callbacks. PipeState struct manages the
async read loop: reader.read() returns a Promise, onReadFulfilled
extracts {done, value}, writes chunks to the writable side, and
recurses via pumpRead() until the stream closes.
2026-03-02 12:17:17 +01:00
Pierre Tachoire
6ed011e2f8 Add pipeThrough and pipeTo to ReadableStream
Implement synchronous piping that drains queued chunks from a
ReadableStream into a WritableStream. pipeThrough accepts any
{readable, writable} pair (TransformStream, TextDecoderStream, etc.)
and returns the readable side. pipeTo writes all chunks to a
WritableStream and resolves when complete.
2026-03-02 12:06:18 +01:00
Pierre Tachoire
23d322452a Add TextDecoderStream to decode UTF-8 byte streams into strings
Mirrors TextEncoderStream: wraps a TransformStream with a Zig-level
transform that converts Uint8Array chunks to strings. Supports the
same constructor options as TextDecoder (label, fatal, ignoreBOM).
2026-03-02 11:49:01 +01:00
Pierre Tachoire
5d3b965d28 Implement WritableStream, TransformStream, and TextEncoderStream
Add the missing Streams API types needed for TextEncoderStream support:
- WritableStream with locked/getWriter, supporting both JS sink callbacks
and internal TransformStream routing
- WritableStreamDefaultWriter with write/close/releaseLock/closed/ready
- WritableStreamDefaultController with error()
- TransformStream with readable/writable accessors, JS transformer
callbacks (start/transform/flush), and Zig-level transform support
- TransformStreamDefaultController with enqueue/error/terminate
- TextEncoderStream that encodes string chunks to UTF-8 Uint8Array
via a Zig-level transform function
2026-03-02 11:49:01 +01:00
Karl Seguin
d9794d72c7 fix bad rebase 2026-03-02 18:39:02 +08:00
Karl Seguin
524b5be937 on iframe re-navigation, keep pending_loads in sync 2026-03-02 18:14:36 +08:00
Karl Seguin
ac2e276a6a try to make test more stable 2026-03-02 18:14:36 +08:00
Karl Seguin
4f4dbc0c22 Allow iframe.src to renavigate the page
Unlike a script, an iframe can be re-navigated simply by setting the src.
2026-03-02 18:14:34 +08:00
Karl Seguin
d56e63a91b On Client.stop, terminate the isolate
Shutdown on MacOS doesn't work properly. The process appears to shutdown, but
will continue to run in the background. It can run infinitely if it's stuck in
a JS loop. To help it along, Client.stop now force-terminates the isolate.

I also don't think shutdown is working as intended on Linux, but the problem
seems less serious there. On Linux, it appears to properly kill the process
(which is the important thing), but I don't think it necessarily does a clean
shutdown.
2026-02-27 08:20:31 +08:00
165 changed files with 8618 additions and 2137 deletions

View File

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

View File

@@ -5,6 +5,7 @@ env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
AWS_CF_DISTRIBUTION: ${{ vars.AWS_CF_DISTRIBUTION }}
LIGHTPANDA_DISABLE_TELEMETRY: true
on:
@@ -73,7 +74,7 @@ jobs:
# use a self host runner.
runs-on: lpd-bench-hetzner
timeout-minutes: 120
timeout-minutes: 180
steps:
- uses: actions/checkout@v6
@@ -107,7 +108,7 @@ jobs:
run: |
./wpt serve 2> /dev/null & echo $! > WPT.pid
sleep 10s
./wptrunner -lpd-path ./lightpanda -json -concurrency 1 > wpt.json
./wptrunner -lpd-path ./lightpanda -json -concurrency 10 -pool 3 > wpt.json
kill `cat WPT.pid`
- name: write commit

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.3.1
ARG ZIG_V8=v0.3.2
ARG TARGETPLATFORM
RUN apt-get update -yq && \
@@ -36,10 +36,6 @@ RUN ZIG=$(grep '\.minimum_zig_version = "' "build.zig.zon" | cut -d'"' -f2) && \
mv zig-${ARCH}-linux-${ZIG} /usr/local/lib && \
ln -s /usr/local/lib/zig-${ARCH}-linux-${ZIG}/zig /usr/local/bin/zig
# install deps
RUN git submodule init && \
git submodule update --recursive
# download and install v8
RUN case $TARGETPLATFORM in \
"linux/arm64") ARCH="aarch64" ;; \

View File

@@ -52,8 +52,19 @@ pub fn build(b: *Build) !void {
mod.addImport("lightpanda", mod); // allow circular "lightpanda" import
mod.addImport("build_config", opts.createModule());
// Format check
const fmt_step = b.step("fmt", "Check code formatting");
const fmt = b.addFmt(.{
.paths = &.{ "src", "build.zig", "build.zig.zon" },
.check = true,
});
fmt_step.dependOn(&fmt.step);
// Set default behavior
b.default_step.dependOn(fmt_step);
try linkV8(b, mod, enable_asan, enable_tsan, prebuilt_v8_path);
try linkCurl(b, mod);
try linkCurl(b, mod, enable_tsan);
try linkHtml5Ever(b, mod);
break :blk mod;
@@ -189,19 +200,19 @@ fn linkHtml5Ever(b: *Build, mod: *Build.Module) !void {
mod.addObjectFile(obj);
}
fn linkCurl(b: *Build, mod: *Build.Module) !void {
fn linkCurl(b: *Build, mod: *Build.Module, is_tsan: bool) !void {
const target = mod.resolved_target.?;
const curl = buildCurl(b, target, mod.optimize.?);
const curl = buildCurl(b, target, mod.optimize.?, is_tsan);
mod.linkLibrary(curl);
const zlib = buildZlib(b, target, mod.optimize.?);
const zlib = buildZlib(b, target, mod.optimize.?, is_tsan);
curl.root_module.linkLibrary(zlib);
const brotli = buildBrotli(b, target, mod.optimize.?);
const brotli = buildBrotli(b, target, mod.optimize.?, is_tsan);
for (brotli) |lib| curl.root_module.linkLibrary(lib);
const nghttp2 = buildNghttp2(b, target, mod.optimize.?);
const nghttp2 = buildNghttp2(b, target, mod.optimize.?, is_tsan);
curl.root_module.linkLibrary(nghttp2);
const boringssl = buildBoringSsl(b, target, mod.optimize.?);
@@ -218,13 +229,14 @@ fn linkCurl(b: *Build, mod: *Build.Module) !void {
}
}
fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *Build.Step.Compile {
fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile {
const dep = b.dependency("zlib", .{});
const mod = b.createModule(.{
.target = target,
.optimize = optimize,
.link_libc = true,
.sanitize_thread = is_tsan,
});
const lib = b.addLibrary(.{ .name = "z", .root_module = mod });
@@ -249,13 +261,14 @@ fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Opti
return lib;
}
fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) [3]*Build.Step.Compile {
fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) [3]*Build.Step.Compile {
const dep = b.dependency("brotli", .{});
const mod = b.createModule(.{
.target = target,
.optimize = optimize,
.link_libc = true,
.sanitize_thread = is_tsan,
});
mod.addIncludePath(dep.path("c/include"));
@@ -311,13 +324,14 @@ fn buildBoringSsl(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin
return .{ ssl, crypto };
}
fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *Build.Step.Compile {
fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile {
const dep = b.dependency("nghttp2", .{});
const mod = b.createModule(.{
.target = target,
.optimize = optimize,
.link_libc = true,
.sanitize_thread = is_tsan,
});
mod.addIncludePath(dep.path("lib/includes"));
@@ -362,6 +376,7 @@ fn buildCurl(
b: *Build,
target: Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
is_tsan: bool,
) *Build.Step.Compile {
const dep = b.dependency("curl", .{});
@@ -369,6 +384,7 @@ fn buildCurl(
.target = target,
.optimize = optimize,
.link_libc = true,
.sanitize_thread = is_tsan,
});
mod.addIncludePath(dep.path("lib"));
mod.addIncludePath(dep.path("include"));

View File

@@ -5,11 +5,10 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.1.tar.gz",
.hash = "v8-0.0.0-xddH64J7BAC81mkf6G9RbEJxS-W3TIRl5iFnShwbqCqy",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.2.tar.gz",
.hash = "v8-0.0.0-xddH6wx-BABNgL7YIDgbnFgKZuXZ68yZNngNSrV6OjrY",
},
//.v8 = .{ .path = "../zig-v8-fork" },
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{
// v1.2.0
.url = "https://github.com/google/brotli/archive/028fb5a23661f123017c060daa546b55cf4bde29.tar.gz",

View File

@@ -25,35 +25,38 @@ const Config = @import("Config.zig");
const Snapshot = @import("browser/js/Snapshot.zig");
const Platform = @import("browser/js/Platform.zig");
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
const RobotStore = @import("browser/Robots.zig").RobotStore;
pub const Http = @import("http/Http.zig");
const Network = @import("network/Runtime.zig");
pub const ArenaPool = @import("ArenaPool.zig");
const App = @This();
http: Http,
network: Network,
config: *const Config,
platform: Platform,
snapshot: Snapshot,
telemetry: Telemetry,
allocator: Allocator,
arena_pool: ArenaPool,
robots: RobotStore,
app_dir_path: ?[]const u8,
shutdown: bool = false,
pub fn init(allocator: Allocator, config: *const Config) !*App {
const app = try allocator.create(App);
errdefer allocator.destroy(app);
app.config = config;
app.allocator = allocator;
app.* = .{
.config = config,
.allocator = allocator,
.network = undefined,
.platform = undefined,
.snapshot = undefined,
.app_dir_path = undefined,
.telemetry = undefined,
.arena_pool = undefined,
};
app.robots = RobotStore.init(allocator);
app.http = try Http.init(allocator, &app.robots, config);
errdefer app.http.deinit();
app.network = try Network.init(allocator, config);
errdefer app.network.deinit();
app.platform = try Platform.init();
errdefer app.platform.deinit();
@@ -72,19 +75,18 @@ pub fn init(allocator: Allocator, config: *const Config) !*App {
return app;
}
pub fn deinit(self: *App) void {
if (@atomicRmw(bool, &self.shutdown, .Xchg, true, .monotonic)) {
return;
}
pub fn shutdown(self: *const App) bool {
return self.network.shutdown.load(.acquire);
}
pub fn deinit(self: *App) void {
const allocator = self.allocator;
if (self.app_dir_path) |app_dir_path| {
allocator.free(app_dir_path);
self.app_dir_path = null;
}
self.telemetry.deinit();
self.robots.deinit();
self.http.deinit();
self.network.deinit();
self.snapshot.deinit();
self.platform.deinit();
self.arena_pool.deinit();

View File

@@ -31,6 +31,7 @@ pub const RunMode = enum {
mcp,
};
pub const MAX_LISTENERS = 16;
pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
// max message size
@@ -153,6 +154,13 @@ pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
};
}
pub fn cdpTimeout(self: *const Config) usize {
return switch (self.mode) {
.serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000,
else => unreachable,
};
}
pub fn maxConnections(self: *const Config) u16 {
return switch (self.mode) {
.serve => |opts| opts.cdp_max_connections,

View File

@@ -21,7 +21,7 @@ const lp = @import("lightpanda");
const log = @import("log.zig");
const Page = @import("browser/Page.zig");
const Transfer = @import("http/Client.zig").Transfer;
const Transfer = @import("browser/HttpClient.zig").Transfer;
const Allocator = std.mem.Allocator;

View File

@@ -18,8 +18,6 @@
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const net = std.net;
const posix = std.posix;
@@ -30,16 +28,13 @@ const log = @import("log.zig");
const App = @import("App.zig");
const Config = @import("Config.zig");
const CDP = @import("cdp/cdp.zig").CDP;
const Net = @import("Net.zig");
const Http = @import("http/Http.zig");
const HttpClient = @import("http/Client.zig");
const Net = @import("network/websocket.zig");
const HttpClient = @import("browser/HttpClient.zig");
const Server = @This();
app: *App,
shutdown: std.atomic.Value(bool) = .init(false),
allocator: Allocator,
listener: ?posix.socket_t,
json_version_response: []const u8,
// Thread management
@@ -48,103 +43,52 @@ clients: std.ArrayList(*Client) = .{},
client_mutex: std.Thread.Mutex = .{},
clients_pool: std.heap.MemoryPool(Client),
pub fn init(app: *App, address: net.Address) !Server {
pub fn init(app: *App, address: net.Address) !*Server {
const allocator = app.allocator;
const json_version_response = try buildJSONVersionResponse(allocator, address);
errdefer allocator.free(json_version_response);
return .{
const self = try allocator.create(Server);
errdefer allocator.destroy(self);
self.* = .{
.app = app,
.listener = null,
.allocator = allocator,
.json_version_response = json_version_response,
.clients_pool = std.heap.MemoryPool(Client).init(app.allocator),
.clients_pool = std.heap.MemoryPool(Client).init(allocator),
};
try self.app.network.bind(address, self, onAccept);
log.info(.app, "server running", .{ .address = address });
return self;
}
/// Interrupts the server so that main can complete normally and call all defer handlers.
pub fn stop(self: *Server) void {
if (self.shutdown.swap(true, .release)) {
return;
}
// Shutdown all active clients
pub fn deinit(self: *Server) void {
// Stop 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).
if (self.listener) |listener| switch (builtin.target.os.tag) {
.linux => posix.shutdown(listener, .recv) catch |err| {
log.warn(.app, "listener shutdown", .{ .err = err });
},
.macos, .freebsd, .netbsd, .openbsd => {
self.listener = null;
posix.close(listener);
},
else => unreachable,
};
}
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;
}
self.clients.deinit(self.allocator);
self.clients_pool.deinit();
self.allocator.free(self.json_version_response);
self.allocator.destroy(self);
}
pub fn run(self: *Server, address: net.Address, timeout_ms: u32) !void {
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;
try posix.setsockopt(listener, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
if (@hasDecl(posix.TCP, "NODELAY")) {
try posix.setsockopt(listener, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1)));
}
try posix.bind(listener, &address.any, address.getOsSockLen());
try posix.listen(listener, self.app.config.maxPendingConnections());
log.info(.app, "server running", .{ .address = address });
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);
continue;
},
}
};
self.spawnWorker(socket, timeout_ms) catch |err| {
log.err(.app, "CDP spawn", .{ .err = err });
posix.close(socket);
};
}
fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void {
const self: *Server = @ptrCast(@alignCast(ctx));
const timeout_ms: u32 = @intCast(self.app.config.cdpTimeout());
self.spawnWorker(socket, timeout_ms) catch |err| {
log.err(.app, "CDP spawn", .{ .err = err });
posix.close(socket);
};
}
fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void {
@@ -173,10 +117,10 @@ fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void
self.registerClient(client);
defer self.unregisterClient(client);
// Check shutdown after registering to avoid missing stop() signal.
// If stop() already iterated over clients, this client won't receive stop()
// Check shutdown after registering to avoid missing the stop signal.
// If deinit() already iterated over clients, this client won't receive stop()
// and would block joinThreads() indefinitely.
if (self.shutdown.load(.acquire)) {
if (self.app.shutdown()) {
return;
}
@@ -213,7 +157,7 @@ fn unregisterClient(self: *Server, client: *Client) void {
}
fn spawnWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void {
if (self.shutdown.load(.acquire)) {
if (self.app.shutdown()) {
return error.ShuttingDown;
}
@@ -283,7 +227,7 @@ pub const Client = struct {
log.info(.app, "client connected", .{ .ip = client_address });
}
const http = try app.http.createClient(allocator);
const http = try HttpClient.init(allocator, &app.network);
errdefer http.deinit();
return .{
@@ -296,6 +240,10 @@ pub const Client = struct {
}
fn stop(self: *Client) void {
switch (self.mode) {
.http => {},
.cdp => |*cdp| cdp.browser.env.terminate(),
}
self.ws.shutdown();
}

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const URL = @import("browser/URL.zig");
const TestHTTPServer = @This();
@@ -97,7 +98,10 @@ fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !voi
}
pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void {
var file = std.fs.cwd().openFile(file_path, .{}) catch |err| switch (err) {
var url_buf: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&url_buf);
const unescaped_file_path = try URL.unescape(fba.allocator(), file_path);
var file = std.fs.cwd().openFile(unescaped_file_path, .{}) catch |err| switch (err) {
error.FileNotFound => return req.respond("server error", .{ .status = .not_found }),
else => return err,
};

View File

@@ -24,7 +24,7 @@ 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 HttpClient = @import("HttpClient.zig");
const ArenaPool = App.ArenaPool;

View File

@@ -205,7 +205,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat
pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void {
event.acquireRef();
defer event.deinit(false, self.page);
defer event.deinit(false, self.page._session);
if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
@@ -234,7 +234,7 @@ pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event,
const page = self.page;
event.acquireRef();
defer event.deinit(false, page);
defer event.deinit(false, page._session);
if (comptime IS_DEBUG) {
log.debug(.event, "dispatchDirect", .{ .type = event._type_string, .context = opts.context });
@@ -365,6 +365,29 @@ fn getFunction(handler: anytype, local: *const js.Local) ?js.Function {
};
}
/// Check if there are any listeners for a direct dispatch (non-DOM target).
/// Use this to avoid creating an event when there are no listeners.
pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []const u8, handler: anytype) bool {
if (hasHandler(handler)) {
return true;
}
return self.lookup.get(.{
.event_target = @intFromPtr(target),
.type_string = .wrap(typ),
}) != null;
}
fn hasHandler(handler: anytype) bool {
const ti = @typeInfo(@TypeOf(handler));
if (ti == .null) {
return false;
}
if (ti == .optional) {
return handler != null;
}
return true;
}
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
@@ -377,12 +400,17 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
const page = self.page;
var was_handled = false;
defer if (was_handled) {
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
};
// Create a single scope for all event handlers in this dispatch.
// This ensures function handles passed to queueMicrotask remain valid
// throughout the entire dispatch, preventing crashes when microtasks run.
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer {
if (was_handled) {
ls.local.runMicrotasks();
}
ls.deinit();
}
const activation_state = ActivationState.create(event, target, page);
@@ -461,7 +489,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
.event_target = @intFromPtr(current_target),
.type_string = event._type_string,
})) |list| {
try self.dispatchPhase(list, current_target, event, &was_handled, comptime .init(true, opts));
try self.dispatchPhase(list, current_target, event, &was_handled, &ls.local, comptime .init(true, opts));
}
}
@@ -476,10 +504,6 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
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) {
@@ -495,7 +519,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
.type_string = event._type_string,
.event_target = @intFromPtr(target_et),
})) |list| {
try self.dispatchPhase(list, target_et, event, &was_handled, comptime .init(null, opts));
try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(null, opts));
if (event._stop_propagation) {
return;
}
@@ -512,7 +536,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
.type_string = event._type_string,
.event_target = @intFromPtr(current_target),
})) |list| {
try self.dispatchPhase(list, current_target, event, &was_handled, comptime .init(false, opts));
try self.dispatchPhase(list, current_target, event, &was_handled, &ls.local, comptime .init(false, opts));
}
}
}
@@ -530,7 +554,7 @@ const DispatchPhaseOpts = struct {
}
};
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime opts: DispatchPhaseOpts) !void {
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, local: *const js.Local, comptime opts: DispatchPhaseOpts) !void {
const page = self.page;
// Track dispatch depth for deferred removal
@@ -607,18 +631,14 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
event._target = getAdjustedTarget(original_target, current_target);
}
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
switch (listener.function) {
.value => |value| try ls.toLocal(value).callWithThis(void, current_target, .{event}),
.value => |value| try local.toLocal(value).callWithThis(void, current_target, .{event}),
.string => |string| {
const str = try page.call_arena.dupeZ(u8, string.str());
try ls.local.eval(str, null);
try local.eval(str, null);
},
.object => |obj_global| {
const obj = ls.toLocal(obj_global);
const obj = local.toLocal(obj_global);
if (try obj.getFunction("handleEvent")) |handleEvent| {
try handleEvent.callWithThis(void, obj, .{event});
}

View File

@@ -48,13 +48,11 @@ const Factory = @This();
_arena: Allocator,
_slab: SlabAllocator,
pub fn init(arena: Allocator) !*Factory {
const self = try arena.create(Factory);
self.* = .{
pub fn init(arena: Allocator) Factory {
return .{
._arena = arena,
._slab = SlabAllocator.init(arena, 128),
};
return self;
}
// this is a root object
@@ -249,16 +247,15 @@ fn eventInit(arena: Allocator, typ: String, value: anytype) !Event {
};
}
pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
pub fn blob(_: *const Factory, arena: Allocator, child: anytype) !*@TypeOf(child) {
// Special case: Blob has slice and mime fields, so we need manual setup
const chain = try PrototypeChain(
&.{ Blob, @TypeOf(child) },
).allocate(allocator);
).allocate(arena);
const blob_ptr = chain.get(0);
blob_ptr.* = .{
._arena = arena,
._type = unionInit(Blob.Type, chain.get(1)),
._slice = "",
._mime = "",
@@ -273,14 +270,16 @@ pub fn abstractRange(self: *Factory, child: anytype, page: *Page) !*@TypeOf(chil
const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(allocator);
const doc = page.document.asNode();
chain.set(0, AbstractRange{
const abstract_range = chain.get(0);
abstract_range.* = AbstractRange{
._type = unionInit(AbstractRange.Type, chain.get(1)),
._end_offset = 0,
._start_offset = 0,
._end_container = doc,
._start_container = doc,
});
};
chain.setLeaf(1, child);
page._live_ranges.append(&abstract_range._range_link);
return chain.get(1);
}

View File

@@ -17,28 +17,29 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const log = @import("../log.zig");
const builtin = @import("builtin");
const posix = std.posix;
const Net = @import("../Net.zig");
const lp = @import("lightpanda");
const log = @import("../log.zig");
const Net = @import("../network/http.zig");
const Network = @import("../network/Runtime.zig");
const Config = @import("../Config.zig");
const URL = @import("../browser/URL.zig");
const Notification = @import("../Notification.zig");
const CookieJar = @import("../browser/webapi/storage/Cookie.zig").Jar;
const Robots = @import("../browser/Robots.zig");
const Robots = @import("../network/Robots.zig");
const RobotStore = Robots.RobotStore;
const posix = std.posix;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const IS_DEBUG = builtin.mode == .Debug;
const Method = Net.Method;
const ResponseHead = Net.ResponseHead;
const HeaderIterator = Net.HeaderIterator;
pub const Method = Net.Method;
pub const Headers = Net.Headers;
pub const ResponseHead = Net.ResponseHead;
pub const HeaderIterator = Net.HeaderIterator;
// This is loosely tied to a browser Page. Loading all the <scripts>, doing
// XHR requests, and loading imports all happens through here. Sine the app
@@ -66,7 +67,7 @@ active: usize,
intercepted: usize,
// Our easy handles, managed by a curl multi.
handles: Handles,
handles: Net.Handles,
// Use to generate the next request ID
next_request_id: u32 = 0,
@@ -77,8 +78,7 @@ queue: TransferQueue,
// The main app allocator
allocator: Allocator,
// Reference to the App-owned Robot Store.
robot_store: *RobotStore,
network: *Network,
// Queue of requests that depend on a robots.txt.
// Allows us to fetch the robots.txt just once.
pending_robots_queue: std.StringHashMapUnmanaged(std.ArrayList(Request)) = .empty,
@@ -97,8 +97,6 @@ http_proxy: ?[:0]const u8 = null,
// CDP.
use_proxy: bool,
config: *const Config,
cdp_client: ?CDPClient = null,
// libcurl can monitor arbitrary sockets, this lets us use libcurl to poll
@@ -121,14 +119,14 @@ pub const CDPClient = struct {
const TransferQueue = std.DoublyLinkedList;
pub fn init(allocator: Allocator, ca_blob: ?Net.Blob, robot_store: *RobotStore, config: *const Config) !*Client {
pub fn init(allocator: Allocator, network: *Network) !*Client {
var transfer_pool = std.heap.MemoryPool(Transfer).init(allocator);
errdefer transfer_pool.deinit();
const client = try allocator.create(Client);
errdefer allocator.destroy(client);
var handles = try Handles.init(allocator, ca_blob, config);
var handles = try Net.Handles.init(allocator, network.ca_blob, network.config);
errdefer handles.deinit(allocator);
// Set transfer callbacks on each connection.
@@ -136,7 +134,7 @@ pub fn init(allocator: Allocator, ca_blob: ?Net.Blob, robot_store: *RobotStore,
try conn.setCallbacks(Transfer.headerCallback, Transfer.dataCallback);
}
const http_proxy = config.httpProxy();
const http_proxy = network.config.httpProxy();
client.* = .{
.queue = .{},
@@ -144,10 +142,9 @@ pub fn init(allocator: Allocator, ca_blob: ?Net.Blob, robot_store: *RobotStore,
.intercepted = 0,
.handles = handles,
.allocator = allocator,
.robot_store = robot_store,
.network = network,
.http_proxy = http_proxy,
.use_proxy = http_proxy != null,
.config = config,
.transfer_pool = transfer_pool,
};
@@ -170,7 +167,7 @@ pub fn deinit(self: *Client) void {
}
pub fn newHeaders(self: *const Client) !Net.Headers {
return Net.Headers.init(self.config.http_headers.user_agent_header);
return Net.Headers.init(self.network.config.http_headers.user_agent_header);
}
pub fn abort(self: *Client) void {
@@ -191,6 +188,8 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
n = node.next;
const conn: *Net.Connection = @fieldParentPtr("node", node);
var transfer = Transfer.fromConnection(conn) catch |err| {
// Let's cleanup what we can
self.handles.remove(conn);
log.err(.http, "get private info", .{ .err = err, .source = "abort" });
continue;
};
@@ -253,12 +252,12 @@ pub fn tick(self: *Client, timeout_ms: u32) !PerformStatus {
}
pub fn request(self: *Client, req: Request) !void {
if (self.config.obeyRobots()) {
if (self.network.config.obeyRobots()) {
const robots_url = try URL.getRobotsUrl(self.allocator, req.url);
errdefer self.allocator.free(robots_url);
// If we have this robots cached, we can take a fast path.
if (self.robot_store.get(robots_url)) |robot_entry| {
if (self.network.robot_store.get(robots_url)) |robot_entry| {
defer self.allocator.free(robots_url);
switch (robot_entry) {
@@ -399,18 +398,18 @@ fn robotsDoneCallback(ctx_ptr: *anyopaque) !void {
switch (ctx.status) {
200 => {
if (ctx.buffer.items.len > 0) {
const robots: ?Robots = ctx.client.robot_store.robotsFromBytes(
ctx.client.config.http_headers.user_agent,
const robots: ?Robots = ctx.client.network.robot_store.robotsFromBytes(
ctx.client.network.config.http_headers.user_agent,
ctx.buffer.items,
) catch blk: {
log.warn(.browser, "failed to parse robots", .{ .robots_url = ctx.robots_url });
// If we fail to parse, we just insert it as absent and ignore.
try ctx.client.robot_store.putAbsent(ctx.robots_url);
try ctx.client.network.robot_store.putAbsent(ctx.robots_url);
break :blk null;
};
if (robots) |r| {
try ctx.client.robot_store.put(ctx.robots_url, r);
try ctx.client.network.robot_store.put(ctx.robots_url, r);
const path = URL.getPathname(ctx.req.url);
allowed = r.isAllowed(path);
}
@@ -419,12 +418,12 @@ fn robotsDoneCallback(ctx_ptr: *anyopaque) !void {
404 => {
log.debug(.http, "robots not found", .{ .url = ctx.robots_url });
// If we get a 404, we just insert it as absent.
try ctx.client.robot_store.putAbsent(ctx.robots_url);
try ctx.client.network.robot_store.putAbsent(ctx.robots_url);
},
else => {
log.debug(.http, "unexpected status on robots", .{ .url = ctx.robots_url, .status = ctx.status });
// If we get an unexpected status, we just insert as absent.
try ctx.client.robot_store.putAbsent(ctx.robots_url);
try ctx.client.network.robot_store.putAbsent(ctx.robots_url);
},
}
@@ -607,7 +606,7 @@ fn makeTransfer(self: *Client, req: Request) !*Transfer {
.req = req,
.ctx = req.ctx,
.client = self,
.max_response_size = self.config.httpMaxResponseSize(),
.max_response_size = self.network.config.httpMaxResponseSize(),
};
return transfer;
}
@@ -665,7 +664,7 @@ pub fn restoreOriginalProxy(self: *Client) !void {
}
// Enable TLS verification on all connections.
pub fn enableTlsVerify(self: *const Client) !void {
pub fn enableTlsVerify(self: *Client) !void {
// Remove inflight connections check on enable TLS b/c chromiumoxide calls
// the command during navigate and Curl seems to accept it...
@@ -675,7 +674,7 @@ pub fn enableTlsVerify(self: *const Client) !void {
}
// Disable TLS verification on all connections.
pub fn disableTlsVerify(self: *const Client) !void {
pub fn disableTlsVerify(self: *Client) !void {
// Remove inflight connections check on disable TLS b/c chromiumoxide calls
// the command during navigate and Curl seems to accept it...
@@ -689,7 +688,11 @@ fn makeRequest(self: *Client, conn: *Net.Connection, transfer: *Transfer) anyerr
{
transfer._conn = conn;
errdefer transfer.deinit();
errdefer {
transfer._conn = null;
transfer.deinit();
self.handles.isAvailable(conn);
}
try conn.setURL(req.url);
try conn.setMethod(req.method);
@@ -700,7 +703,7 @@ fn makeRequest(self: *Client, conn: *Net.Connection, transfer: *Transfer) anyerr
}
var header_list = req.headers;
try conn.secretHeaders(&header_list, &self.config.http_headers); // Add headers that must be hidden from intercepts
try conn.secretHeaders(&header_list, &self.network.config.http_headers); // Add headers that must be hidden from intercepts
try conn.setHeaders(&header_list);
// Add cookies.
@@ -712,21 +715,28 @@ fn makeRequest(self: *Client, conn: *Net.Connection, transfer: *Transfer) anyerr
// add credentials
if (req.credentials) |creds| {
try conn.setProxyCredentials(creds);
if (transfer._auth_challenge != null and transfer._auth_challenge.?.source == .proxy) {
try conn.setProxyCredentials(creds);
} else {
try conn.setCredentials(creds);
}
}
}
// Once soon as this is called, our "perform" loop is responsible for
// As soon as this is called, our "perform" loop is responsible for
// cleaning things up. That's why the above code is in a block. If anything
// fails BEFORE `curl_multi_add_handle` suceeds, the we still need to do
// fails BEFORE `curl_multi_add_handle` succeeds, the we still need to do
// cleanup. But if things fail after `curl_multi_add_handle`, we expect
// perfom to pickup the failure and cleanup.
try self.handles.add(conn);
self.handles.add(conn) catch |err| {
transfer._conn = null;
transfer.deinit();
self.handles.isAvailable(conn);
return err;
};
if (req.start_callback) |cb| {
cb(transfer) catch |err| {
self.handles.remove(conn);
transfer._conn = null;
transfer.deinit();
return err;
};
@@ -834,7 +844,7 @@ fn processMessages(self: *Client) !bool {
// In case of request w/o data, we need to call the header done
// callback now.
const proceed = transfer.headerDoneCallback(&msg.conn) catch |err| {
log.err(.http, "header_done_callback", .{ .err = err });
log.err(.http, "header_done_callback2", .{ .err = err });
requestFailed(transfer, err, true);
continue;
};
@@ -872,8 +882,6 @@ fn ensureNoActiveConnection(self: *const Client) !void {
}
}
const Handles = Net.Handles;
pub const RequestCookie = struct {
is_http: bool,
jar: *CookieJar,
@@ -1300,9 +1308,9 @@ pub const Transfer = struct {
// WWW-Authenticate or Proxy-Authenticate header.
transfer._auth_challenge = .{
.status = status,
.source = undefined,
.scheme = undefined,
.realm = undefined,
.source = null,
.scheme = null,
.realm = null,
};
return buf_len;
}

View File

@@ -54,6 +54,7 @@ const Performance = @import("webapi/Performance.zig");
const Screen = @import("webapi/Screen.zig");
const VisualViewport = @import("webapi/VisualViewport.zig");
const PerformanceObserver = @import("webapi/PerformanceObserver.zig");
const AbstractRange = @import("webapi/AbstractRange.zig");
const MutationObserver = @import("webapi/MutationObserver.zig");
const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
@@ -62,13 +63,13 @@ const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
const Http = App.Http;
const Net = @import("../Net.zig");
const HttpClient = @import("HttpClient.zig");
const ArenaPool = App.ArenaPool;
const timestamp = @import("../datetime.zig").timestamp;
const milliTimestamp = @import("../datetime.zig").milliTimestamp;
const IFrame = Element.Html.IFrame;
const WebApiURL = @import("webapi/URL.zig");
const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup;
@@ -142,6 +143,9 @@ _to_load: std.ArrayList(*Element.Html) = .{},
_script_manager: ScriptManager,
// List of active live ranges (for mutation updates per DOM spec)
_live_ranges: std.DoublyLinkedList = .{},
// List of active MutationObservers
_mutation_observers: std.DoublyLinkedList = .{},
_mutation_delivery_scheduled: bool = false,
@@ -190,6 +194,8 @@ _queued_navigation: ?*QueuedNavigation = null,
// The URL of the current page
url: [:0]const u8 = "about:blank",
origin: ?[]const u8 = null,
// 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.
@@ -212,18 +218,10 @@ arena: Allocator,
// from JS. Best arena to use, when possible.
call_arena: Allocator,
arena_pool: *ArenaPool,
// In Debug, we use this to see if anything fails to release an arena back to
// the pool.
_arena_pool_leak_track: (if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct {
owner: []const u8,
count: usize,
}) else void) = if (IS_DEBUG) .empty else {},
parent: ?*Page,
window: *Window,
document: *Document,
iframe: ?*Element.Html.IFrame = null,
iframe: ?*IFrame = null,
frames: std.ArrayList(*Page) = .{},
frames_sorted: bool = true,
@@ -236,7 +234,7 @@ version: usize = 0,
// ScriptManager, so all scripts just count as 1 pending load.
_pending_loads: u32,
_parent_notified: if (IS_DEBUG) bool else void = if (IS_DEBUG) false else {},
_parent_notified: bool = false,
_type: enum { root, frame }, // only used for logs right now
_req_id: u32 = 0,
@@ -246,17 +244,11 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
if (comptime IS_DEBUG) {
log.debug(.page, "page.init", .{});
}
const browser = session.browser;
const arena_pool = browser.arena_pool;
const page_arena = if (parent) |p| p.arena else try arena_pool.acquire();
errdefer if (parent == null) arena_pool.release(page_arena);
var factory = if (parent) |p| p._factory else try Factory.init(page_arena);
const call_arena = try arena_pool.acquire();
errdefer arena_pool.release(call_arena);
const call_arena = try session.getArena(.{ .debug = "call_arena" });
errdefer session.releaseArena(call_arena);
const factory = &session.factory;
const document = (try factory.document(Node.Document.HTMLDocument{
._proto = undefined,
})).asDocument();
@@ -264,10 +256,9 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
self.* = .{
.js = undefined,
.parent = parent,
.arena = page_arena,
.arena = session.page_arena,
.document = document,
.window = undefined,
.arena_pool = arena_pool,
.call_arena = call_arena,
._frame_id = frame_id,
._session = session,
@@ -275,7 +266,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
._pending_loads = 1, // always 1 for the ScriptManager
._type = if (parent == null) .root else .frame,
._script_manager = undefined,
._event_manager = EventManager.init(page_arena, self),
._event_manager = EventManager.init(session.page_arena, self),
};
var screen: *Screen = undefined;
@@ -303,6 +294,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
._visual_viewport = visual_viewport,
});
const browser = session.browser;
self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self);
errdefer self._script_manager.deinit();
@@ -323,9 +315,9 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
}
}
pub fn deinit(self: *Page) void {
pub fn deinit(self: *Page, abort_http: bool) void {
for (self.frames.items) |frame| {
frame.deinit();
frame.deinit(abort_http);
}
if (comptime IS_DEBUG) {
@@ -338,31 +330,28 @@ pub fn deinit(self: *Page) void {
// stats.print(&stream) catch unreachable;
}
const session = self._session;
if (self._queued_navigation) |qn| {
self.arena_pool.release(qn.arena);
session.releaseArena(qn.arena);
}
const session = self._session;
session.browser.env.destroyContext(self.js);
self._script_manager.shutdown = true;
session.browser.http_client.abort();
self._script_manager.deinit();
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, .type = self._type, .url = self.url });
}
}
}
self.arena_pool.release(self.call_arena);
if (self.parent == null) {
self.arena_pool.release(self.arena);
session.browser.http_client.abort();
} else if (abort_http) {
// a small optimization, it's faster to abort _everything_ on the root
// page, so we prefer that. But if it's just the frame that's going
// away (a frame navigation) then we'll abort the frame-related requests
session.browser.http_client.abortFrame(self._frame_id);
}
self._script_manager.deinit();
session.releaseArena(self.call_arena);
}
pub fn base(self: *const Page) [:0]const u8 {
@@ -376,14 +365,10 @@ pub fn getTitle(self: *Page) !?[]const u8 {
return null;
}
pub fn getOrigin(self: *Page, allocator: Allocator) !?[]const u8 {
return try URL.getOrigin(allocator, self.url);
}
// Add comon headers for a request:
// * cookies
// * referer
pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *Http.Headers) !void {
pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *HttpClient.Headers) !void {
try self.requestCookie(.{}).headersForRequest(temp, url, headers);
// Build the referer
@@ -406,35 +391,16 @@ pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, header
}
}
const GetArenaOpts = struct {
debug: []const u8,
};
pub fn getArena(self: *Page, comptime opts: GetArenaOpts) !Allocator {
const allocator = try self.arena_pool.acquire();
if (comptime IS_DEBUG) {
const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr));
if (gop.found_existing) {
std.debug.assert(gop.value_ptr.count == 0);
}
gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 };
}
return allocator;
pub fn getArena(self: *Page, comptime opts: Session.GetArenaOpts) !Allocator {
return self._session.getArena(opts);
}
pub fn releaseArena(self: *Page, allocator: Allocator) void {
if (comptime IS_DEBUG) {
const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?;
if (found.count != 1) {
log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count, .type = self._type, .url = self.url });
return;
}
found.count = 0;
}
return self.arena_pool.release(allocator);
return self._session.releaseArena(allocator);
}
pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool {
const current_origin = (try URL.getOrigin(self.call_arena, self.url)) orelse return false;
const current_origin = self.origin orelse return false;
return std.mem.startsWith(u8, url, current_origin);
}
@@ -456,12 +422,23 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
// if the url is about:blank, we load an empty HTML document in the
// page and dispatch the events.
if (std.mem.eql(u8, "about:blank", request_url)) {
self.url = "about:blank";
if (self.parent) |parent| {
self.origin = parent.origin;
} else {
self.origin = null;
}
try self.js.setOrigin(self.origin);
// Assume we parsed the document.
// It's important to force a reset during the following navigation.
self._parse_state = .complete;
// We do not processHTMLDoc here as we know we don't have any scripts
// This assumption may be false when CDP Page.addScriptToEvaluateOnNewDocument is implemented
self.document.injectBlank(self) catch |err| {
log.err(.browser, "inject blank", .{ .err = err });
return error.InjectBlankFailed;
};
self.documentIsComplete();
session.notification.dispatch(.page_navigate, &.{
@@ -500,6 +477,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
var http_client = session.browser.http_client;
self.url = try self.arena.dupeZ(u8, request_url);
self.origin = try URL.getOrigin(self.arena, self.url);
self._req_id = req_id;
self._navigated_options = .{
@@ -552,57 +530,92 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
};
}
// We cannot navigate immediately as navigating will delete the DOM tree,
// which holds this event's node.
// As such we schedule the function to be called as soon as possible.
pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOpts, priority: NavigationPriority) !void {
if (self.canScheduleNavigation(priority) == false) {
// Navigation can happen in many places, such as executing a <script> tag or
// a JavaScript callback, a CDP command, etc...It's rarely safe to do immediately
// as the caller almost certainly does'nt expect the page to go away during the
// call. So, we schedule the navigation for the next tick.
pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void {
if (self.canScheduleNavigation(std.meta.activeTag(nt)) == false) {
return;
}
const arena = try self.arena_pool.acquire();
errdefer self.arena_pool.release(arena);
return self.scheduleNavigationWithArena(arena, request_url, opts, priority);
const arena = try self._session.getArena(.{ .debug = "scheduleNavigation" });
errdefer self._session.releaseArena(arena);
return self.scheduleNavigationWithArena(arena, request_url, opts, nt);
}
fn scheduleNavigationWithArena(self: *Page, arena: Allocator, request_url: []const u8, opts: NavigateOpts, priority: NavigationPriority) !void {
const resolved_url = try URL.resolve(
arena,
self.base(),
request_url,
.{ .always_dupe = true, .encode = true },
);
// Don't name the first parameter "self", because the target of this navigation
// might change inside the function. So the code should be explicit about the
// page that it's acting on.
fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void {
const resolved_url, const is_about_blank = blk: {
if (std.mem.eql(u8, request_url, "about:blank")) {
// navigate will handle this special case
break :blk .{ "about:blank", true };
}
const u = try URL.resolve(
arena,
originator.base(),
request_url,
.{ .always_dupe = true, .encode = true },
);
break :blk .{ u, false };
};
const session = self._session;
if (!opts.force and URL.eqlDocument(self.url, resolved_url)) {
self.arena_pool.release(arena);
const target = switch (nt) {
.script => |p| p orelse originator,
.iframe => |iframe| iframe._window.?._page, // only an frame with existing content (i.e. a window) can be navigated
.anchor, .form => |node| blk: {
const doc = node.ownerDocument(originator) orelse break :blk originator;
break :blk doc._page orelse originator;
},
};
self.url = try self.arena.dupeZ(u8, resolved_url);
self.window._location = try Location.init(self.url, self);
self.document._location = self.window._location;
return session.navigation.updateEntries(self.url, opts.kind, self, true);
const session = target._session;
if (!opts.force and URL.eqlDocument(target.url, resolved_url)) {
target.url = try target.arena.dupeZ(u8, resolved_url);
target.window._location = try Location.init(target.url, target);
target.document._location = target.window._location;
if (target.parent == null) {
try session.navigation.updateEntries(target.url, opts.kind, target, true);
}
// don't defer this, the caller is responsible for freeing it on error
session.releaseArena(arena);
return;
}
log.info(.browser, "schedule navigation", .{
.url = resolved_url,
.reason = opts.reason,
.target = resolved_url,
.type = self._type,
.type = target._type,
});
session.browser.http_client.abort();
// This is a micro-optimization. Terminate any inflight request as early
// as we can. This will be more propery shutdown when we process the
// scheduled navigation.
if (target.parent == null) {
session.browser.http_client.abort();
} else {
// This doesn't terminate any inflight requests for nested frames, but
// again, this is just an optimization. We'll correctly shut down all
// nested inflight requests when we process the navigation.
session.browser.http_client.abortFrame(target._frame_id);
}
const qn = try arena.create(QueuedNavigation);
qn.* = .{
.opts = opts,
.arena = arena,
.url = resolved_url,
.priority = priority,
.is_about_blank = is_about_blank,
.navigation_type = std.meta.activeTag(nt),
};
if (self._queued_navigation) |existing| {
self.arena_pool.release(existing.arena);
if (target._queued_navigation) |existing| {
session.releaseArena(existing.arena);
}
self._queued_navigation = qn;
target._queued_navigation = qn;
return session.scheduleNavigation(target);
}
// A script can have multiple competing navigation events, say it starts off
@@ -610,23 +623,31 @@ fn scheduleNavigationWithArena(self: *Page, arena: Allocator, request_url: []con
// You might think that we just stop at the first one, but that doesn't seem
// to be what browsers do, and it isn't particularly well supported by v8 (i.e.
// halting execution mid-script).
// From what I can tell, there are 3 "levels" of priority, in order:
// From what I can tell, there are 4 "levels" of priority, in order:
// 1 - form submission
// 2 - JavaScript apis (e.g. top.location)
// 3 - anchor clicks
// 4 - iframe.src =
// Within, each category, it's last-one-wins.
fn canScheduleNavigation(self: *Page, priority: NavigationPriority) bool {
const existing = self._queued_navigation orelse return true;
fn canScheduleNavigation(self: *Page, new_target_type: NavigationType) bool {
if (self.parent) |parent| {
if (parent.isGoingAway()) {
return false;
}
}
if (existing.priority == priority) {
const existing_target_type = (self._queued_navigation orelse return true).navigation_type;
if (existing_target_type == new_target_type) {
// same reason, than this latest one wins
return true;
}
return switch (existing.priority) {
.anchor => true, // everything is higher priority than an anchor
return switch (existing_target_type) {
.iframe => true, // everything is higher priority than iframe.src = "x"
.anchor => new_target_type != .iframe, // an anchor is only higher priority than an iframe
.form => false, // nothing is higher priority than a form
.script => priority == .form, // a form is higher priority than a script
.script => new_target_type == .form, // a form is higher priority than a script
};
}
@@ -658,7 +679,7 @@ pub fn scriptsCompletedLoading(self: *Page) void {
self.pendingLoadCompleted();
}
pub fn iframeCompletedLoading(self: *Page, iframe: *Element.Html.IFrame) void {
pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void {
blk: {
var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls);
@@ -707,17 +728,18 @@ pub fn documentIsComplete(self: *Page) void {
log.err(.page, "document is complete", .{ .err = err, .type = self._type, .url = self.url });
};
if (IS_DEBUG) {
std.debug.assert(self._navigated_options != null);
if (self._navigated_options) |no| {
// _navigated_options will be null in special short-circuit cases, like
// "navigating" to about:blank, in which case this notification has
// already been sent
self._session.notification.dispatch(.page_navigated, &.{
.frame_id = self._frame_id,
.req_id = self._req_id,
.opts = no,
.url = self.url,
.timestamp = timestamp(.monotonic),
});
}
self._session.notification.dispatch(.page_navigated, &.{
.frame_id = self._frame_id,
.req_id = self._req_id,
.opts = self._navigated_options.?,
.url = self.url,
.timestamp = timestamp(.monotonic),
});
}
fn _documentIsComplete(self: *Page) !void {
@@ -727,45 +749,50 @@ fn _documentIsComplete(self: *Page) !void {
try self.dispatchLoad();
// Dispatch window.load event.
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
// This event is weird, it's dispatched directly on the window, but
// with the document as the target.
event._target = self.document.asEventTarget();
try self._event_manager.dispatchDirect(
self.window.asEventTarget(),
event,
self.window._on_load,
.{ .inject_target = false, .context = "page load" },
);
const window_target = self.window.asEventTarget();
if (self._event_manager.hasDirectListeners(window_target, "load", self.window._on_load)) {
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
// This event is weird, it's dispatched directly on the window, but
// with the document as the target.
event._target = self.document.asEventTarget();
try self._event_manager.dispatchDirect(window_target, event, self.window._on_load, .{ .inject_target = false, .context = "page load" });
}
const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent();
try self._event_manager.dispatchDirect(
self.window.asEventTarget(),
pageshow_event,
self.window._on_pageshow,
.{ .context = "page show" },
);
if (self._event_manager.hasDirectListeners(window_target, "pageshow", self.window._on_pageshow)) {
const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent();
try self._event_manager.dispatchDirect(window_target, pageshow_event, self.window._on_pageshow, .{ .context = "page show" });
}
self.notifyParentLoadComplete();
}
fn notifyParentLoadComplete(self: *Page) void {
if (comptime IS_DEBUG) {
std.debug.assert(self._parent_notified == false);
self._parent_notified = true;
const parent = self.parent orelse return;
if (self._parent_notified == true) {
if (comptime IS_DEBUG) {
std.debug.assert(false);
}
// shouldn't happen, don't want to crash a release build over it
return;
}
if (self.parent) |p| {
p.iframeCompletedLoading(self.iframe.?);
}
self._parent_notified = true;
parent.iframeCompletedLoading(self.iframe.?);
}
fn pageHeaderDoneCallback(transfer: *Http.Transfer) !bool {
fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
var self: *Page = @ptrCast(@alignCast(transfer.ctx));
// would be different than self.url in the case of a redirect
const header = &transfer.response_header.?;
self.url = try self.arena.dupeZ(u8, std.mem.span(header.url));
const response_url = std.mem.span(header.url);
if (std.mem.eql(u8, response_url, self.url) == false) {
// would be different than self.url in the case of a redirect
self.url = try self.arena.dupeZ(u8, response_url);
self.origin = try URL.getOrigin(self.arena, self.url);
}
try self.js.setOrigin(self.origin);
self.window._location = try Location.init(self.url, self);
self.document._location = self.window._location;
@@ -782,7 +809,7 @@ fn pageHeaderDoneCallback(transfer: *Http.Transfer) !bool {
return true;
}
fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void {
fn pageDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
var self: *Page = @ptrCast(@alignCast(transfer.ctx));
if (self._parse_state == .pre) {
@@ -796,7 +823,12 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void {
} orelse .unknown;
if (comptime IS_DEBUG) {
log.debug(.page, "navigate first chunk", .{ .content_type = mime.content_type, .len = data.len, .type = self._type, .url = self.url });
log.debug(.page, "navigate first chunk", .{
.content_type = mime.content_type,
.len = data.len,
.type = self._type,
.url = self.url,
});
}
switch (mime.content_type) {
@@ -850,7 +882,11 @@ fn pageDoneCallback(ctx: *anyopaque) !void {
try self._session.navigation.commitNavigation(self);
defer if (comptime IS_DEBUG) {
log.debug(.page, "page.load.complete", .{ .url = self.url, .type = self._type });
log.debug(.page, "page load complete", .{
.url = self.url,
.type = self._type,
.state = std.meta.activeTag(self._parse_state),
});
};
const parse_arena = try self.getArena(.{ .debug = "Page.parse" });
@@ -928,7 +964,11 @@ fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void {
}
pub fn isGoingAway(self: *const Page) bool {
return self._queued_navigation != null;
if (self._queued_navigation != null) {
return true;
}
const parent = self.parent orelse return false;
return parent.isGoingAway();
}
pub fn scriptAddedCallback(self: *Page, comptime from_parser: bool, script: *Element.Html.Script) !void {
@@ -947,7 +987,7 @@ pub fn scriptAddedCallback(self: *Page, comptime from_parser: bool, script: *Ele
};
}
pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void {
pub fn iframeAddedCallback(self: *Page, iframe: *IFrame) !void {
if (self.isGoingAway()) {
// if we're planning on navigating to another page, don't load this iframe
return;
@@ -956,41 +996,61 @@ pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void {
return;
}
const src = iframe.asElement().getAttributeSafe(comptime .wrap("src")) orelse return;
var src = iframe.asElement().getAttributeSafe(comptime .wrap("src")) orelse "";
if (src.len == 0) {
return;
src = "about:blank";
}
if (iframe._window != null) {
// This frame is being re-navigated. We need to do this through a
// scheduleNavigation phase. We can't navigate immediately here, for
// the same reason that a "root" page can't immediately navigate:
// we could be in the middle of a JS callback or something else that
// doesn't exit the page to just suddenly go away.
return self.scheduleNavigation(src, .{
.reason = .script,
.kind = .{ .push = null },
}, .{ .iframe = iframe });
}
iframe._executed = true;
const session = self._session;
const frame_id = session.nextFrameId();
const page_frame = try self.arena.create(Page);
const frame_id = session.nextFrameId();
try Page.init(page_frame, frame_id, session, self);
errdefer page_frame.deinit(true);
self._pending_loads += 1;
page_frame.iframe = iframe;
iframe._content_window = page_frame.window;
iframe._window = page_frame.window;
errdefer iframe._window = null;
// on first load, dispatch frame_created evnet
self._session.notification.dispatch(.page_frame_created, &.{
.frame_id = frame_id,
.parent_id = self._frame_id,
.timestamp = timestamp(.monotonic),
});
// navigate will dupe the url
const url = try URL.resolve(
self.call_arena,
self.base(),
src,
.{ .encode = true },
);
const url = blk: {
if (std.mem.eql(u8, src, "about:blank")) {
break :blk "about:blank"; // navigate will handle this special case
}
break :blk try URL.resolve(
self.call_arena, // ok to use, page.navigate dupes this
self.base(),
src,
.{ .encode = true },
);
};
page_frame.navigate(url, .{ .reason = .initialFrameNavigation }) catch |err| {
log.warn(.page, "iframe navigate failure", .{ .url = url, .err = err });
self._pending_loads -= 1;
iframe._content_window = null;
page_frame.deinit();
iframe._window = null;
page_frame.deinit(true);
return error.IFrameLoadError;
};
@@ -1825,7 +1885,7 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const
Element.Html.Track,
namespace,
attribute_iterator,
.{ ._proto = undefined },
.{ ._proto = undefined, ._kind = comptime .wrap("subtitles"), ._ready_state = .none },
),
else => {},
},
@@ -1909,7 +1969,7 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const
.{ ._proto = undefined },
),
asUint("iframe") => return self.createHtmlElementT(
Element.Html.IFrame,
IFrame,
namespace,
attribute_iterator,
.{ ._proto = undefined },
@@ -1942,10 +2002,10 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const
.{ ._proto = undefined, ._tag_name = String.init(undefined, "article", .{}) catch unreachable, ._tag = .article },
),
asUint("details") => return self.createHtmlElementT(
Element.Html.Generic,
Element.Html.Details,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "details", .{}) catch unreachable, ._tag = .details },
.{ ._proto = undefined },
),
asUint("summary") => return self.createHtmlElementT(
Element.Html.Generic,
@@ -2338,6 +2398,12 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
const previous_sibling = child.previousSibling();
const next_sibling = child.nextSibling();
// Capture child's index before removal for live range updates (DOM spec remove steps 4-7)
const child_index_for_ranges: ?u32 = if (self._live_ranges.first != null)
parent.getChildIndex(child)
else
null;
const children = parent._children.?;
switch (children.*) {
.one => |n| {
@@ -2366,6 +2432,11 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
child._parent = null;
child._child_link = .{};
// Update live ranges for removal (DOM spec remove steps 4-7)
if (child_index_for_ranges) |idx| {
self.updateRangesForNodeRemoval(parent, child, idx);
}
// Handle slot assignment removal before mutation observers
if (child.is(Element)) |el| {
// Check if the parent was a shadow host
@@ -2469,7 +2540,7 @@ pub fn insertNodeRelative(self: *Page, parent: *Node, child: *Node, relative: In
pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Node, child: *Node, relative: InsertNodeRelative, opts: InsertNodeOpts) !void {
// caller should have made sure this was the case
lp.assert(child._parent == null, "Page.insertNodeRelative parent", .{ .url = self.url });
lp.assert(child._parent == null, "Page.insertNodeRelative parent", .{});
const children = blk: {
// expand parent._children so that it can take another child
@@ -2513,6 +2584,21 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod
}
child._parent = parent;
// Update live ranges for insertion (DOM spec insert step 6).
// For .before/.after the child was inserted at a specific position;
// ranges on parent with offsets past that position must be incremented.
// For .append no range update is needed (spec: "if child is non-null").
if (self._live_ranges.first != null) {
switch (relative) {
.append => {},
.before, .after => {
if (parent.getChildIndex(child)) |idx| {
self.updateRangesForNodeInsertion(parent, idx);
}
},
}
}
// Tri-state behavior for mutations:
// 1. from_parser=true, parse_mode=document -> no mutations (initial document parse)
// 2. from_parser=true, parse_mode=fragment -> mutations (innerHTML additions)
@@ -2771,6 +2857,54 @@ pub fn childListChange(
}
}
// --- Live range update methods (DOM spec §4.2.3, §4.2.4, §4.7, §4.8) ---
/// Update all live ranges after a replaceData mutation on a CharacterData node.
/// Per DOM spec: insertData = replaceData(offset, 0, data),
/// deleteData = replaceData(offset, count, "").
/// All parameters are in UTF-16 code unit offsets.
pub fn updateRangesForCharacterDataReplace(self: *Page, target: *Node, offset: u32, count: u32, data_len: u32) void {
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
while (it) |link| : (it = link.next) {
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
ar.updateForCharacterDataReplace(target, offset, count, data_len);
}
}
/// Update all live ranges after a splitText operation.
/// Steps 7b-7e of the DOM spec splitText algorithm.
/// Steps 7d-7e complement (not overlap) updateRangesForNodeInsertion:
/// the insert update handles offsets > child_index, while 7d/7e handle
/// offsets == node_index+1 (these are equal values but with > vs == checks).
pub fn updateRangesForSplitText(self: *Page, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void {
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
while (it) |link| : (it = link.next) {
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
ar.updateForSplitText(target, new_node, offset, parent, node_index);
}
}
/// Update all live ranges after a node insertion.
/// Per DOM spec insert algorithm step 6: only applies when inserting before a
/// non-null reference node.
pub fn updateRangesForNodeInsertion(self: *Page, parent: *Node, child_index: u32) void {
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
while (it) |link| : (it = link.next) {
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
ar.updateForNodeInsertion(parent, child_index);
}
}
/// Update all live ranges after a node removal.
/// Per DOM spec remove algorithm steps 4-7.
pub fn updateRangesForNodeRemoval(self: *Page, parent: *Node, child: *Node, child_index: u32) void {
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
while (it) |link| : (it = link.next) {
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
ar.updateForNodeRemoval(parent, child, child_index);
}
}
// TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '')
pub fn parseHtmlAsChildren(self: *Page, node: *Node, html: []const u8) !void {
const previous_parse_mode = self._parse_mode;
@@ -2811,21 +2945,19 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void {
}
if (node.is(Element.Html.Script)) |script| {
if ((comptime from_parser == false) and script._src.len == 0) {
// script was added via JavaScript, but without a src, don't try
// to execute it (we'll execute it if/when the src is set)
return;
// Script was added via JavaScript without a src attribute.
// Only skip if it has no inline content either — scripts with
// textContent/text should still execute per spec.
if (node.firstChild() == null) {
return;
}
}
self.scriptAddedCallback(from_parser, script) catch |err| {
log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "script", .type = self._type, .url = self.url });
return err;
};
} else if (node.is(Element.Html.IFrame)) |iframe| {
if ((comptime from_parser == false) and iframe._src.len == 0) {
// iframe was added via JavaScript, but without a src
return;
}
} else if (node.is(IFrame)) |iframe| {
self.iframeAddedCallback(iframe) catch |err| {
log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "iframe", .type = self._type, .url = self.url });
return err;
@@ -2953,7 +3085,7 @@ pub const NavigateReason = enum {
pub const NavigateOpts = struct {
cdp_id: ?i64 = null,
reason: NavigateReason = .address_bar,
method: Http.Method = .GET,
method: HttpClient.Method = .GET,
body: ?[]const u8 = null,
header: ?[:0]const u8 = null,
force: bool = false,
@@ -2963,20 +3095,29 @@ pub const NavigateOpts = struct {
pub const NavigatedOpts = struct {
cdp_id: ?i64 = null,
reason: NavigateReason = .address_bar,
method: Http.Method = .GET,
method: HttpClient.Method = .GET,
};
const NavigationPriority = enum {
const NavigationType = enum {
form,
script,
anchor,
iframe,
};
const Navigation = union(NavigationType) {
form: *Node,
script: ?*Page,
anchor: *Node,
iframe: *IFrame,
};
pub const QueuedNavigation = struct {
arena: Allocator,
url: [:0]const u8,
opts: NavigateOpts,
priority: NavigationPriority,
is_about_blank: bool,
navigation_type: NavigationType,
};
pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void {
@@ -3029,11 +3170,17 @@ pub fn handleClick(self: *Page, target: *Node) !void {
return;
}
// TODO: We need to support targets properly, but this is the most
// common case: a click on an anchor navigates the page/frame that
// anchor is in.
// ownerDocument only returns null when `target` is a document, which
// it is NOT in this case. Even for a detched node, it'll return self.document
try element.focus(self);
try self.scheduleNavigation(href, .{
.reason = .script,
.kind = .{ .push = null },
}, .anchor);
}, .{ .anchor = target });
},
.input => |input| {
try element.focus(self);
@@ -3054,7 +3201,11 @@ pub fn handleClick(self: *Page, target: *Node) !void {
pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void {
const event = keyboard_event.asEvent();
const element = self.window._document._active_element orelse return;
const element = self.window._document._active_element orelse {
keyboard_event.deinit(false, self._session);
return;
};
if (comptime IS_DEBUG) {
log.debug(.page, "page keydown", .{
.url = self.url,
@@ -3127,7 +3278,7 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form
// so submit_event is still valid when we check _prevent_default
submit_event.acquireRef();
defer submit_event.deinit(false, self);
defer submit_event.deinit(false, self._session);
try self._event_manager.dispatch(form_element.asEventTarget(), submit_event);
// If the submit event was prevented, don't submit the form
@@ -3141,8 +3292,8 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form
// I don't think this is technically correct, but FormData handles it ok
const form_data = try FormData.init(form, submitter_, self);
const arena = try self.arena_pool.acquire();
errdefer self.arena_pool.release(arena);
const arena = try self._session.getArena(.{ .debug = "submitForm" });
errdefer self._session.releaseArena(arena);
const encoding = form_element.getAttributeSafe(comptime .wrap("enctype"));
@@ -3164,7 +3315,7 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form
} else {
action = try URL.concatQueryString(arena, action, buf.written());
}
return self.scheduleNavigationWithArena(arena, action, opts, .form);
return self.scheduleNavigationWithArena(arena, action, opts, .{ .form = form_element.asNode() });
}
// insertText is a shortcut to insert text into the active element.
@@ -3189,7 +3340,7 @@ const RequestCookieOpts = struct {
is_http: bool = true,
is_navigation: bool = false,
};
pub fn requestCookie(self: *const Page, opts: RequestCookieOpts) Http.Client.RequestCookie {
pub fn requestCookie(self: *const Page, opts: RequestCookieOpts) HttpClient.RequestCookie {
return .{
.jar = &self._session.cookie_jar,
.origin = self.url,

View File

@@ -21,7 +21,8 @@ const lp = @import("lightpanda");
const builtin = @import("builtin");
const log = @import("../log.zig");
const Http = @import("../http/Http.zig");
const HttpClient = @import("HttpClient.zig");
const net_http = @import("../network/http.zig");
const String = @import("../string.zig").String;
const js = @import("js/js.zig");
@@ -60,7 +61,7 @@ ready_scripts: std.DoublyLinkedList,
shutdown: bool = false,
client: *Http.Client,
client: *HttpClient,
allocator: Allocator,
buffer_pool: BufferPool,
@@ -88,7 +89,7 @@ importmap: std.StringHashMapUnmanaged([:0]const u8),
// event).
page_notified_of_completion: bool,
pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) ScriptManager {
pub fn init(allocator: Allocator, http_client: *HttpClient, page: *Page) ScriptManager {
return .{
.page = page,
.async_scripts = .{},
@@ -141,7 +142,7 @@ fn clearList(list: *std.DoublyLinkedList) void {
}
}
pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !Http.Headers {
pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !net_http.Headers {
var headers = try self.client.newHeaders();
try self.page.headersForRequest(self.page.arena, url, &headers);
return headers;
@@ -634,6 +635,8 @@ pub const Script = struct {
debug_transfer_notified_fail: bool = false,
debug_transfer_redirecting: bool = false,
debug_transfer_intercept_state: u8 = 0,
debug_transfer_auth_challenge: bool = false,
debug_transfer_easy_id: usize = 0,
const Kind = enum {
module,
@@ -673,11 +676,11 @@ pub const Script = struct {
self.manager.script_pool.destroy(self);
}
fn startCallback(transfer: *Http.Transfer) !void {
fn startCallback(transfer: *HttpClient.Transfer) !void {
log.debug(.http, "script fetch start", .{ .req = transfer });
}
fn headerCallback(transfer: *Http.Transfer) !bool {
fn headerCallback(transfer: *HttpClient.Transfer) !bool {
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
const header = &transfer.response_header.?;
self.status = header.status;
@@ -711,6 +714,8 @@ pub const Script = struct {
.a5 = self.debug_transfer_notified_fail,
.a6 = self.debug_transfer_redirecting,
.a7 = self.debug_transfer_intercept_state,
.a8 = self.debug_transfer_auth_challenge,
.a9 = self.debug_transfer_easy_id,
.b1 = transfer.id,
.b2 = transfer._tries,
.b3 = transfer.aborted,
@@ -718,6 +723,8 @@ pub const Script = struct {
.b5 = transfer._notified_fail,
.b6 = transfer._redirecting,
.b7 = @intFromEnum(transfer._intercept_state),
.b8 = transfer._auth_challenge != null,
.b9 = if (transfer._conn) |c| @intFromPtr(c.easy) else 0,
});
self.header_callback_called = true;
self.debug_transfer_id = transfer.id;
@@ -727,6 +734,8 @@ pub const Script = struct {
self.debug_transfer_notified_fail = transfer._notified_fail;
self.debug_transfer_redirecting = transfer._redirecting;
self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c.easy) else 0;
}
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });
@@ -738,14 +747,14 @@ pub const Script = struct {
return true;
}
fn dataCallback(transfer: *Http.Transfer, data: []const u8) !void {
fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
self._dataCallback(transfer, data) catch |err| {
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = transfer, .len = data.len });
return err;
};
}
fn _dataCallback(self: *Script, _: *Http.Transfer, data: []const u8) !void {
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void {
try self.source.remote.appendSlice(self.manager.allocator, data);
}

View File

@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const builtin = @import("builtin");
const log = @import("../log.zig");
const App = @import("../App.zig");
const js = @import("js/js.zig");
const storage = @import("webapi/storage/storage.zig");
@@ -29,47 +30,86 @@ const History = @import("webapi/History.zig");
const Page = @import("Page.zig");
const Browser = @import("Browser.zig");
const Factory = @import("Factory.zig");
const Notification = @import("../Notification.zig");
const QueuedNavigation = Page.QueuedNavigation;
const Allocator = std.mem.Allocator;
const ArenaPool = App.ArenaPool;
const IS_DEBUG = builtin.mode == .Debug;
// Session is like a browser's tab.
// It owns the js env and the loader for all the pages of the session.
// You can create successively multiple pages for a session, but you must
// deinit a page before running another one.
// deinit a page before running another one. It manages two distinct lifetimes.
//
// The first is the lifetime of the Session itself, where pages are created and
// removed, but share the same cookie jar and navigation history (etc...)
//
// The second is as a container the data needed by the full page hierarchy, i.e. \
// the root page and all of its frames (and all of their frames.)
const Session = @This();
// These are the fields that remain intact for the duration of the Session
browser: *Browser,
notification: *Notification,
// Used to create our Inspector and in the BrowserContext.
arena: Allocator,
cookie_jar: storage.Cookie.Jar,
storage_shed: storage.Shed,
history: History,
navigation: Navigation,
storage_shed: storage.Shed,
notification: *Notification,
cookie_jar: storage.Cookie.Jar,
// These are the fields that get reset whenever the Session's page (the root) is reset.
factory: Factory,
page_arena: Allocator,
// Origin map for same-origin context sharing. Scoped to the root page lifetime.
origins: std.StringHashMapUnmanaged(*js.Origin) = .empty,
// Shared resources for all pages in this session.
// These live for the duration of the page tree (root + frames).
arena_pool: *ArenaPool,
// In Debug, we use this to see if anything fails to release an arena back to
// the pool.
_arena_pool_leak_track: if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct {
owner: []const u8,
count: usize,
}) else void = if (IS_DEBUG) .empty else {},
page: ?Page,
queued_navigation: std.ArrayList(*Page),
// Temporary buffer for about:blank navigations during processing.
// We process async navigations first (safe from re-entrance), then sync
// about:blank navigations (which may add to queued_navigation).
queued_queued_navigation: std.ArrayList(*Page),
frame_id_gen: u32,
pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void {
const allocator = browser.app.allocator;
const arena = try browser.arena_pool.acquire();
errdefer browser.arena_pool.release(arena);
const arena_pool = browser.arena_pool;
const arena = try arena_pool.acquire();
errdefer arena_pool.release(arena);
const page_arena = try arena_pool.acquire();
errdefer arena_pool.release(page_arena);
self.* = .{
.page = null,
.arena = arena,
.arena_pool = arena_pool,
.page_arena = page_arena,
.factory = Factory.init(page_arena),
.history = .{},
.frame_id_gen = 0,
// The prototype (EventTarget) for Navigation is created when a Page is created.
.navigation = .{ ._proto = undefined },
.storage_shed = .{},
.browser = browser,
.queued_navigation = .{},
.queued_queued_navigation = .{},
.notification = notification,
.cookie_jar = storage.Cookie.Jar.init(allocator),
};
@@ -79,11 +119,11 @@ pub fn deinit(self: *Session) void {
if (self.page != null) {
self.removePage();
}
const browser = self.browser;
self.cookie_jar.deinit();
self.storage_shed.deinit(browser.app.allocator);
browser.arena_pool.release(self.arena);
self.storage_shed.deinit(self.browser.app.allocator);
self.arena_pool.release(self.page_arena);
self.arena_pool.release(self.arena);
}
// NOTE: the caller is not the owner of the returned value,
@@ -113,33 +153,137 @@ pub fn removePage(self: *Session) void {
self.notification.dispatch(.page_remove, .{});
lp.assert(self.page != null, "Session.removePage - page is null", .{});
self.page.?.deinit();
self.page.?.deinit(false);
self.page = null;
self.navigation.onRemovePage();
self.resetPageResources();
if (comptime IS_DEBUG) {
log.debug(.browser, "remove page", .{});
}
}
pub const GetArenaOpts = struct {
debug: []const u8,
};
pub fn getArena(self: *Session, opts: GetArenaOpts) !Allocator {
const allocator = try self.arena_pool.acquire();
if (comptime IS_DEBUG) {
// Use session's arena (not page_arena) since page_arena gets reset between pages
const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr));
if (gop.found_existing and gop.value_ptr.count != 0) {
log.err(.bug, "ArenaPool Double Use", .{ .owner = gop.value_ptr.*.owner });
@panic("ArenaPool Double Use");
}
gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 };
}
return allocator;
}
pub fn releaseArena(self: *Session, allocator: Allocator) void {
if (comptime IS_DEBUG) {
const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?;
if (found.count != 1) {
log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count });
if (comptime builtin.is_test) {
@panic("ArenaPool Double Free");
}
return;
}
found.count = 0;
}
return self.arena_pool.release(allocator);
}
pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin {
const key = key_ orelse {
var opaque_origin: [36]u8 = undefined;
@import("../id.zig").uuidv4(&opaque_origin);
// Origin.init will dupe opaque_origin. It's fine that this doesn't
// get added to self.origins. In fact, it further isolates it. When the
// context is freed, it'll call session.releaseOrigin which will free it.
return js.Origin.init(self.browser.app, self.browser.env.isolate, &opaque_origin);
};
const gop = try self.origins.getOrPut(self.arena, key);
if (gop.found_existing) {
const origin = gop.value_ptr.*;
origin.rc += 1;
return origin;
}
errdefer _ = self.origins.remove(key);
const origin = try js.Origin.init(self.browser.app, self.browser.env.isolate, key);
gop.key_ptr.* = origin.key;
gop.value_ptr.* = origin;
return origin;
}
pub fn releaseOrigin(self: *Session, origin: *js.Origin) void {
const rc = origin.rc;
if (rc == 1) {
_ = self.origins.remove(origin.key);
origin.deinit(self.browser.app);
} else {
origin.rc = rc - 1;
}
}
/// Reset page_arena and factory for a clean slate.
/// Called when root page is removed.
fn resetPageResources(self: *Session) void {
// Check for arena leaks before releasing
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.clearRetainingCapacity();
}
// All origins should have been released when contexts were destroyed
if (comptime IS_DEBUG) {
std.debug.assert(self.origins.count() == 0);
}
// Defensive cleanup in case origins leaked
{
const app = self.browser.app;
var it = self.origins.valueIterator();
while (it.next()) |value| {
value.*.deinit(app);
}
self.origins.clearRetainingCapacity();
}
// Release old page_arena and acquire fresh one
self.frame_id_gen = 0;
self.arena_pool.reset(self.page_arena, 64 * 1024);
self.factory = Factory.init(self.page_arena);
}
pub fn replacePage(self: *Session) !*Page {
if (comptime IS_DEBUG) {
log.debug(.browser, "replace page", .{});
}
lp.assert(self.page != null, "Session.replacePage null page", .{});
lp.assert(self.page.?.parent == null, "Session.replacePage with parent", .{});
var current = self.page.?;
const frame_id = current._frame_id;
const parent = current.parent;
current.deinit();
current.deinit(true);
self.resetPageResources();
self.browser.env.memoryPressureNotification(.moderate);
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, frame_id, self, parent);
try Page.init(page, frame_id, self, null);
return page;
}
@@ -174,10 +318,11 @@ pub fn wait(self: *Session, wait_ms: u32) WaitResult {
switch (wait_result) {
.done => {
if (page._queued_navigation == null) {
if (self.queued_navigation.items.len == 0) {
return .done;
}
page = self.processScheduledNavigation(page) catch return .done;
self.processQueuedNavigation() catch return .done;
page = &self.page.?; // might have changed
},
else => |result| return result,
}
@@ -229,7 +374,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
}
},
.html, .complete => {
if (page._queued_navigation != null) {
if (self.queued_navigation.items.len != 0) {
return .done;
}
@@ -261,7 +406,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
std.debug.assert(http_client.intercepted == 0);
}
const ms: u64 = ms_to_next_task orelse blk: {
var ms: u64 = ms_to_next_task orelse blk: {
if (wait_ms - ms_remaining < 100) {
if (comptime builtin.is_test) {
return .done;
@@ -288,7 +433,13 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
// Same as above, except we have a scheduled task,
// it just happens to be too far into the future
// compared to how long we were told to wait.
return .done;
if (!browser.hasBackgroundTasks()) {
return .done;
}
// _we_ have nothing to run, but v8 is working on
// background tasks. We'll wait for them.
browser.waitForBackgroundTasks();
ms = 20;
}
// We have a task to run in the not-so-distant future.
@@ -339,42 +490,145 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
}
}
fn processScheduledNavigation(self: *Session, current_page: *Page) !*Page {
const browser = self.browser;
pub fn scheduleNavigation(self: *Session, page: *Page) !void {
const list = &self.queued_navigation;
const qn = current_page._queued_navigation.?;
// take ownership of the page's queued navigation
current_page._queued_navigation = null;
defer browser.arena_pool.release(qn.arena);
// Check if page is already queued
for (list.items) |existing| {
if (existing == page) {
// Already queued
return;
}
}
const frame_id, const parent = blk: {
const page = &self.page.?;
const frame_id = page._frame_id;
const parent = page.parent;
return list.append(self.arena, page);
}
browser.http_client.abort();
self.removePage();
fn processQueuedNavigation(self: *Session) !void {
const navigations = &self.queued_navigation;
break :blk .{ frame_id, parent };
if (self.page.?._queued_navigation != null) {
// This is both an optimization and a simplification of sorts. If the
// root page is navigating, then we don't need to process any other
// navigation. Also, the navigation for the root page and for a frame
// is different enough that have two distinct code blocks is, imo,
// better. Yes, there will be duplication.
navigations.clearRetainingCapacity();
return self.processRootQueuedNavigation();
}
const about_blank_queue = &self.queued_queued_navigation;
defer about_blank_queue.clearRetainingCapacity();
// First pass: process async navigations (non-about:blank)
// These cannot cause re-entrant navigation scheduling
for (navigations.items) |page| {
const qn = page._queued_navigation.?;
if (qn.is_about_blank) {
// Defer about:blank to second pass
try about_blank_queue.append(self.arena, page);
continue;
}
try self.processFrameNavigation(page, qn);
}
// Clear the queue after first pass
navigations.clearRetainingCapacity();
// Second pass: process synchronous navigations (about:blank)
// These may trigger new navigations which go into queued_navigation
for (about_blank_queue.items) |page| {
const qn = page._queued_navigation.?;
try self.processFrameNavigation(page, qn);
}
// Safety: Remove any about:blank navigations that were queued during the
// second pass to prevent infinite loops
var i: usize = 0;
while (i < navigations.items.len) {
const page = navigations.items[i];
if (page._queued_navigation) |qn| {
if (qn.is_about_blank) {
log.warn(.page, "recursive about blank", .{});
_ = navigations.swapRemove(i);
continue;
}
}
i += 1;
}
}
fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !void {
lp.assert(page.parent != null, "root queued navigation", .{});
const iframe = page.iframe.?;
const parent = page.parent.?;
page._queued_navigation = null;
defer self.releaseArena(qn.arena);
errdefer iframe._window = null;
if (page._parent_notified) {
// we already notified the parent that we had loaded
parent._pending_loads += 1;
}
const frame_id = page._frame_id;
page.deinit(true);
page.* = undefined;
try Page.init(page, frame_id, self, parent);
errdefer page.deinit(true);
page.iframe = iframe;
iframe._window = page.window;
page.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued frame navigation error", .{ .err = err });
return err;
};
}
fn processRootQueuedNavigation(self: *Session) !void {
const current_page = &self.page.?;
const frame_id = current_page._frame_id;
// create a copy before the page is cleared
const qn = current_page._queued_navigation.?;
current_page._queued_navigation = null;
defer self.arena_pool.release(qn.arena);
// HACK
// Mark as released in tracking BEFORE removePage clears the map.
// We can't call releaseArena() because that would also return the arena
// to the pool, making the memory invalid before we use qn.url/qn.opts.
if (comptime IS_DEBUG) {
if (self._arena_pool_leak_track.getPtr(@intFromPtr(qn.arena.ptr))) |found| {
found.count = 0;
}
}
self.removePage();
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, frame_id, self, parent);
const new_page = &self.page.?;
try Page.init(new_page, frame_id, self, null);
// Creates a new NavigationEventTarget for this page.
try self.navigation.onNewPage(page);
try self.navigation.onNewPage(new_page);
// start JS env
// Inform CDP the main page has been created such that additional context for other Worlds can be created as well
self.notification.dispatch(.page_created, page);
self.notification.dispatch(.page_created, new_page);
page.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued navigation error", .{ .err = err, .url = qn.url });
new_page.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued navigation error", .{ .err = err });
return err;
};
return page;
}
pub fn nextFrameId(self: *Session) u32 {

View File

@@ -167,17 +167,17 @@ pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 {
const query_end = if (query_start) |_| (fragment_start orelse url.len) else path_end;
const path_to_encode = url[path_start..path_end];
const encoded_path = try percentEncodeSegment(allocator, path_to_encode, true);
const encoded_path = try percentEncodeSegment(allocator, path_to_encode, .path);
const encoded_query = if (query_start) |qs| blk: {
const query_to_encode = url[qs + 1 .. query_end];
const encoded = try percentEncodeSegment(allocator, query_to_encode, false);
const encoded = try percentEncodeSegment(allocator, query_to_encode, .query);
break :blk encoded;
} else null;
const encoded_fragment = if (fragment_start) |fs| blk: {
const fragment_to_encode = url[fs + 1 ..];
const encoded = try percentEncodeSegment(allocator, fragment_to_encode, false);
const encoded = try percentEncodeSegment(allocator, fragment_to_encode, .query);
break :blk encoded;
} else null;
@@ -204,11 +204,13 @@ pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 {
return buf.items[0 .. buf.items.len - 1 :0];
}
fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_path: bool) ![]const u8 {
const EncodeSet = enum { path, query, userinfo };
fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 {
// Check if encoding is needed
var needs_encoding = false;
for (segment) |c| {
if (shouldPercentEncode(c, is_path)) {
if (shouldPercentEncode(c, encode_set)) {
needs_encoding = true;
break;
}
@@ -235,7 +237,7 @@ fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_p
}
}
if (shouldPercentEncode(c, is_path)) {
if (shouldPercentEncode(c, encode_set)) {
try buf.writer(allocator).print("%{X:0>2}", .{c});
} else {
try buf.append(allocator, c);
@@ -245,16 +247,17 @@ fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_p
return buf.items;
}
fn shouldPercentEncode(c: u8, comptime is_path: bool) bool {
fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool {
return switch (c) {
// Unreserved characters (RFC 3986)
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => false,
// sub-delims allowed in both path and query
'!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => false,
// Separators allowed in both path and query
'/', ':', '@' => false,
// Query-specific: '?' is allowed in queries but not in paths
'?' => comptime is_path,
// sub-delims allowed in path/query but some must be encoded in userinfo
'!', '$', '&', '\'', '(', ')', '*', '+', ',' => false,
';', '=' => encode_set == .userinfo,
// Separators: userinfo must encode these
'/', ':', '@' => encode_set == .userinfo,
// '?' is allowed in queries but not in paths or userinfo
'?' => encode_set != .query,
// Everything else needs encoding (including space)
else => true,
};
@@ -514,7 +517,7 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) !
const search = getSearch(current);
const hash = getHash(current);
// Check if the host includes a port
// Check if the new value includes a port
const colon_pos = std.mem.lastIndexOfScalar(u8, value, ':');
const clean_host = if (colon_pos) |pos| blk: {
const port_str = value[pos + 1 ..];
@@ -526,7 +529,14 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) !
break :blk value[0..pos];
}
break :blk value;
} else value;
} else blk: {
// No port in new value - preserve existing port
const current_port = getPort(current);
if (current_port.len > 0) {
break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ value, current_port });
}
break :blk value;
};
return buildUrl(allocator, protocol, clean_host, pathname, search, hash);
}
@@ -544,6 +554,9 @@ pub fn setHostname(current: [:0]const u8, value: []const u8, allocator: Allocato
pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator) ![:0]const u8 {
const hostname = getHostname(current);
const protocol = getProtocol(current);
const pathname = getPathname(current);
const search = getSearch(current);
const hash = getHash(current);
// Handle null or default ports
const new_host = if (value) |port_str| blk: {
@@ -560,7 +573,7 @@ pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator)
break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ hostname, port_str });
} else hostname;
return setHost(current, new_host, allocator);
return buildUrl(allocator, protocol, new_host, pathname, search, hash);
}
pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
@@ -608,6 +621,64 @@ pub fn setHash(current: [:0]const u8, value: []const u8, allocator: Allocator) !
return buildUrl(allocator, protocol, host, pathname, search, hash);
}
pub fn setUsername(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
const protocol = getProtocol(current);
const host = getHost(current);
const pathname = getPathname(current);
const search = getSearch(current);
const hash = getHash(current);
const password = getPassword(current);
const encoded_username = try percentEncodeSegment(allocator, value, .userinfo);
return buildUrlWithUserInfo(allocator, protocol, encoded_username, password, host, pathname, search, hash);
}
pub fn setPassword(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
const protocol = getProtocol(current);
const host = getHost(current);
const pathname = getPathname(current);
const search = getSearch(current);
const hash = getHash(current);
const username = getUsername(current);
const encoded_password = try percentEncodeSegment(allocator, value, .userinfo);
return buildUrlWithUserInfo(allocator, protocol, username, encoded_password, host, pathname, search, hash);
}
fn buildUrlWithUserInfo(
allocator: Allocator,
protocol: []const u8,
username: []const u8,
password: []const u8,
host: []const u8,
pathname: []const u8,
search: []const u8,
hash: []const u8,
) ![:0]const u8 {
if (username.len == 0 and password.len == 0) {
return buildUrl(allocator, protocol, host, pathname, search, hash);
} else if (password.len == 0) {
return std.fmt.allocPrintSentinel(allocator, "{s}//{s}@{s}{s}{s}{s}", .{
protocol,
username,
host,
pathname,
search,
hash,
}, 0);
} else {
return std.fmt.allocPrintSentinel(allocator, "{s}//{s}:{s}@{s}{s}{s}{s}", .{
protocol,
username,
password,
host,
pathname,
search,
hash,
}, 0);
}
}
pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []const u8) ![:0]const u8 {
if (query_string.len == 0) {
return arena.dupeZ(u8, url);
@@ -961,6 +1032,10 @@ test "URL: ensureEncoded" {
.url = "https://example.com/path?value=100% done",
.expected = "https://example.com/path?value=100%25%20done",
},
.{
.url = "about:blank",
.expected = "about:blank",
},
};
for (cases) |case| {

View File

@@ -480,10 +480,11 @@ fn consumeName(self: *Tokenizer) []const u8 {
self.consumeEscape();
},
0x0 => self.advance(1),
'\x80'...'\xBF', '\xC0'...'\xEF', '\xF0'...'\xFF' => {
// This byte *is* part of a multi-byte code point,
// well end up copying the whole code point before this loop does something else.
self.advance(1);
'\x80'...'\xFF' => {
// Non-ASCII: advance over the complete UTF-8 code point in one step.
// Using consumeChar() instead of advance(1) ensures we never land on
// a continuation byte, which advance() asserts against.
self.consumeChar();
},
else => {
if (self.hasNonAsciiAt(0)) {

526
src/browser/interactive.zig Normal file
View File

@@ -0,0 +1,526 @@
// 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 URL = @import("URL.zig");
const TreeWalker = @import("webapi/TreeWalker.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const Allocator = std.mem.Allocator;
pub const InteractivityType = enum {
native,
aria,
contenteditable,
listener,
focusable,
};
pub const InteractiveElement = struct {
node: *Node,
tag_name: []const u8,
role: ?[]const u8,
name: ?[]const u8,
interactivity_type: InteractivityType,
listener_types: []const []const u8,
disabled: bool,
tab_index: i32,
id: ?[]const u8,
class: ?[]const u8,
href: ?[]const u8,
input_type: ?[]const u8,
value: ?[]const u8,
element_name: ?[]const u8,
placeholder: ?[]const u8,
pub fn jsonStringify(self: *const InteractiveElement, jw: anytype) !void {
try jw.beginObject();
try jw.objectField("tagName");
try jw.write(self.tag_name);
try jw.objectField("role");
try jw.write(self.role);
try jw.objectField("name");
try jw.write(self.name);
try jw.objectField("type");
try jw.write(@tagName(self.interactivity_type));
if (self.listener_types.len > 0) {
try jw.objectField("listeners");
try jw.beginArray();
for (self.listener_types) |lt| {
try jw.write(lt);
}
try jw.endArray();
}
if (self.disabled) {
try jw.objectField("disabled");
try jw.write(true);
}
try jw.objectField("tabIndex");
try jw.write(self.tab_index);
if (self.id) |v| {
try jw.objectField("id");
try jw.write(v);
}
if (self.class) |v| {
try jw.objectField("class");
try jw.write(v);
}
if (self.href) |v| {
try jw.objectField("href");
try jw.write(v);
}
if (self.input_type) |v| {
try jw.objectField("inputType");
try jw.write(v);
}
if (self.value) |v| {
try jw.objectField("value");
try jw.write(v);
}
if (self.element_name) |v| {
try jw.objectField("elementName");
try jw.write(v);
}
if (self.placeholder) |v| {
try jw.objectField("placeholder");
try jw.write(v);
}
try jw.endObject();
}
};
/// Collect all interactive elements under `root`.
pub fn collectInteractiveElements(
root: *Node,
arena: Allocator,
page: *Page,
) ![]InteractiveElement {
// Pre-build a map of event_target pointer → event type names,
// so classify and getListenerTypes are both O(1) per element.
const listener_targets = try buildListenerTargetMap(page, arena);
var results: std.ArrayList(InteractiveElement) = .empty;
var tw = TreeWalker.Full.init(root, .{});
while (tw.next()) |node| {
const el = node.is(Element) orelse continue;
const html_el = el.is(Element.Html) orelse continue;
// Skip non-visual elements that are never user-interactive.
switch (el.getTag()) {
.script, .style, .link, .meta, .head, .noscript, .template => continue,
else => {},
}
const itype = classifyInteractivity(el, html_el, listener_targets) orelse continue;
const listener_types = getListenerTypes(
el.asEventTarget(),
listener_targets,
);
try results.append(arena, .{
.node = node,
.tag_name = el.getTagNameLower(),
.role = getRole(el),
.name = getAccessibleName(el),
.interactivity_type = itype,
.listener_types = listener_types,
.disabled = isDisabled(el),
.tab_index = html_el.getTabIndex(),
.id = el.getAttributeSafe(comptime .wrap("id")),
.class = el.getAttributeSafe(comptime .wrap("class")),
.href = if (el.getAttributeSafe(comptime .wrap("href"))) |href|
URL.resolve(arena, page.base(), href, .{ .encode = true }) catch href
else
null,
.input_type = getInputType(el),
.value = getInputValue(el),
.element_name = el.getAttributeSafe(comptime .wrap("name")),
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
});
}
return results.items;
}
const ListenerTargetMap = std.AutoHashMapUnmanaged(usize, std.ArrayList([]const u8));
/// Pre-build a map from event_target pointer → list of event type names.
/// This lets both classifyInteractivity (O(1) "has any?") and
/// getListenerTypes (O(1) "which ones?") avoid re-iterating per element.
fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap {
var map = ListenerTargetMap{};
// addEventListener registrations
var it = page._event_manager.lookup.iterator();
while (it.next()) |entry| {
const list = entry.value_ptr.*;
if (list.first != null) {
const gop = try map.getOrPut(arena, entry.key_ptr.event_target);
if (!gop.found_existing) gop.value_ptr.* = .empty;
try gop.value_ptr.append(arena, entry.key_ptr.type_string.str());
}
}
// Inline handlers (onclick, onmousedown, etc.)
var attr_it = page._event_target_attr_listeners.iterator();
while (attr_it.next()) |entry| {
const gop = try map.getOrPut(arena, @intFromPtr(entry.key_ptr.target));
if (!gop.found_existing) gop.value_ptr.* = .empty;
// Strip "on" prefix to get the event type name.
try gop.value_ptr.append(arena, @tagName(entry.key_ptr.handler)[2..]);
}
return map;
}
fn classifyInteractivity(
el: *Element,
html_el: *Element.Html,
listener_targets: ListenerTargetMap,
) ?InteractivityType {
// 1. Native interactive by tag
switch (el.getTag()) {
.button, .summary, .details, .select, .textarea => return .native,
.anchor, .area => {
if (el.getAttributeSafe(comptime .wrap("href")) != null) return .native;
},
.input => {
if (el.is(Element.Html.Input)) |input| {
if (input._input_type != .hidden) return .native;
}
},
else => {},
}
// 2. ARIA interactive role
if (el.getAttributeSafe(comptime .wrap("role"))) |role| {
if (isInteractiveRole(role)) return .aria;
}
// 3. contenteditable (15 bytes, exceeds SSO limit for comptime)
if (el.getAttributeSafe(.wrap("contenteditable"))) |ce| {
if (ce.len == 0 or std.ascii.eqlIgnoreCase(ce, "true")) return .contenteditable;
}
// 4. Event listeners (addEventListener or inline handlers)
const et_ptr = @intFromPtr(html_el.asEventTarget());
if (listener_targets.get(et_ptr) != null) return .listener;
// 5. Explicitly focusable via tabindex.
// Only count elements with an EXPLICIT tabindex attribute,
// since getTabIndex() returns 0 for all interactive tags by default
// (including anchors without href and hidden inputs).
if (el.getAttributeSafe(comptime .wrap("tabindex"))) |_| {
if (html_el.getTabIndex() >= 0) return .focusable;
}
return null;
}
fn isInteractiveRole(role: []const u8) bool {
const interactive_roles = [_][]const u8{
"button", "link", "tab", "menuitem",
"menuitemcheckbox", "menuitemradio", "switch", "checkbox",
"radio", "slider", "spinbutton", "searchbox",
"combobox", "option", "treeitem",
};
for (interactive_roles) |r| {
if (std.ascii.eqlIgnoreCase(role, r)) return true;
}
return false;
}
fn getRole(el: *Element) ?[]const u8 {
// Explicit role attribute takes precedence
if (el.getAttributeSafe(comptime .wrap("role"))) |role| return role;
// Implicit role from tag
return switch (el.getTag()) {
.button, .summary => "button",
.anchor, .area => if (el.getAttributeSafe(comptime .wrap("href")) != null) "link" else null,
.input => blk: {
if (el.is(Element.Html.Input)) |input| {
break :blk switch (input._input_type) {
.text, .tel, .url, .email => "textbox",
.checkbox => "checkbox",
.radio => "radio",
.button, .submit, .reset, .image => "button",
.range => "slider",
.number => "spinbutton",
.search => "searchbox",
else => null,
};
}
break :blk null;
},
.select => "combobox",
.textarea => "textbox",
.details => "group",
else => null,
};
}
fn getAccessibleName(el: *Element) ?[]const u8 {
// aria-label
if (el.getAttributeSafe(comptime .wrap("aria-label"))) |v| {
if (v.len > 0) return v;
}
// alt (for img, input[type=image])
if (el.getAttributeSafe(comptime .wrap("alt"))) |v| {
if (v.len > 0) return v;
}
// title
if (el.getAttributeSafe(comptime .wrap("title"))) |v| {
if (v.len > 0) return v;
}
// placeholder
if (el.getAttributeSafe(comptime .wrap("placeholder"))) |v| {
if (v.len > 0) return v;
}
// value (for buttons)
if (el.getTag() == .input) {
if (el.getAttributeSafe(comptime .wrap("value"))) |v| {
if (v.len > 0) return v;
}
}
// Text content (first non-empty text node, trimmed)
return getTextContent(el.asNode());
}
fn getTextContent(node: *Node) ?[]const u8 {
var tw = TreeWalker.FullExcludeSelf.init(node, .{});
while (tw.next()) |child| {
// Skip text inside script/style elements.
if (child.is(Element)) |el| {
switch (el.getTag()) {
.script, .style => {
tw.skipChildren();
continue;
},
else => {},
}
}
if (child.is(Node.CData)) |cdata| {
if (cdata.is(Node.CData.Text)) |text| {
const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace);
if (content.len > 0) return content;
}
}
}
return null;
}
fn isDisabled(el: *Element) bool {
if (el.getAttributeSafe(comptime .wrap("disabled")) != null) return true;
return isDisabledByFieldset(el);
}
/// Check if an element is disabled by an ancestor <fieldset disabled>.
/// Per spec, elements inside the first <legend> child of a disabled fieldset
/// are NOT disabled by that fieldset.
fn isDisabledByFieldset(el: *Element) bool {
const element_node = el.asNode();
var current: ?*Node = element_node._parent;
while (current) |node| {
current = node._parent;
const ancestor = node.is(Element) orelse continue;
if (ancestor.getTag() == .fieldset and ancestor.getAttributeSafe(comptime .wrap("disabled")) != null) {
// Check if element is inside the first <legend> child of this fieldset
var child = ancestor.firstElementChild();
while (child) |c| {
if (c.getTag() == .legend) {
if (c.asNode().contains(element_node)) return false;
break;
}
child = c.nextElementSibling();
}
return true;
}
}
return false;
}
fn getInputType(el: *Element) ?[]const u8 {
if (el.is(Element.Html.Input)) |input| {
return input._input_type.toString();
}
return null;
}
fn getInputValue(el: *Element) ?[]const u8 {
if (el.is(Element.Html.Input)) |input| {
return input.getValue();
}
return null;
}
/// Get all event listener types registered on this target.
fn getListenerTypes(target: *EventTarget, listener_targets: ListenerTargetMap) []const []const u8 {
if (listener_targets.get(@intFromPtr(target))) |types| return types.items;
return &.{};
}
const testing = @import("../testing.zig");
fn testInteractive(html: []const u8) ![]InteractiveElement {
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);
return collectInteractiveElements(div.asNode(), page.call_arena, page);
}
test "browser.interactive: button" {
const elements = try testInteractive("<button>Click me</button>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual("button", elements[0].tag_name);
try testing.expectEqual("button", elements[0].role.?);
try testing.expectEqual("Click me", elements[0].name.?);
try testing.expectEqual(InteractivityType.native, elements[0].interactivity_type);
}
test "browser.interactive: anchor with href" {
const elements = try testInteractive("<a href=\"/page\">Link</a>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual("a", elements[0].tag_name);
try testing.expectEqual("link", elements[0].role.?);
try testing.expectEqual("Link", elements[0].name.?);
}
test "browser.interactive: anchor without href" {
const elements = try testInteractive("<a>Not a link</a>");
try testing.expectEqual(0, elements.len);
}
test "browser.interactive: input types" {
const elements = try testInteractive(
\\<input type="text" placeholder="Search">
\\<input type="hidden" name="csrf">
);
try testing.expectEqual(1, elements.len);
try testing.expectEqual("input", elements[0].tag_name);
try testing.expectEqual("text", elements[0].input_type.?);
try testing.expectEqual("Search", elements[0].placeholder.?);
}
test "browser.interactive: select and textarea" {
const elements = try testInteractive(
\\<select name="color"><option>Red</option></select>
\\<textarea name="msg"></textarea>
);
try testing.expectEqual(2, elements.len);
try testing.expectEqual("select", elements[0].tag_name);
try testing.expectEqual("textarea", elements[1].tag_name);
}
test "browser.interactive: aria role" {
const elements = try testInteractive("<div role=\"button\">Custom</div>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual("div", elements[0].tag_name);
try testing.expectEqual("button", elements[0].role.?);
try testing.expectEqual(InteractivityType.aria, elements[0].interactivity_type);
}
test "browser.interactive: contenteditable" {
const elements = try testInteractive("<div contenteditable=\"true\">Edit me</div>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual(InteractivityType.contenteditable, elements[0].interactivity_type);
}
test "browser.interactive: tabindex" {
const elements = try testInteractive("<div tabindex=\"0\">Focusable</div>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual(InteractivityType.focusable, elements[0].interactivity_type);
try testing.expectEqual(@as(i32, 0), elements[0].tab_index);
}
test "browser.interactive: disabled" {
const elements = try testInteractive("<button disabled>Off</button>");
try testing.expectEqual(1, elements.len);
try testing.expect(elements[0].disabled);
}
test "browser.interactive: disabled by fieldset" {
const elements = try testInteractive(
\\<fieldset disabled>
\\ <button>Disabled</button>
\\ <legend><button>In legend</button></legend>
\\</fieldset>
);
try testing.expectEqual(2, elements.len);
// Button outside legend is disabled by fieldset
try testing.expect(elements[0].disabled);
// Button inside first legend is NOT disabled
try testing.expect(!elements[1].disabled);
}
test "browser.interactive: non-interactive div" {
const elements = try testInteractive("<div>Just text</div>");
try testing.expectEqual(0, elements.len);
}
test "browser.interactive: details and summary" {
const elements = try testInteractive("<details><summary>More</summary><p>Content</p></details>");
try testing.expectEqual(2, elements.len);
try testing.expectEqual("details", elements[0].tag_name);
try testing.expectEqual("summary", elements[1].tag_name);
}
test "browser.interactive: mixed elements" {
const elements = try testInteractive(
\\<div>
\\ <a href="/home">Home</a>
\\ <p>Some text</p>
\\ <button id="btn1">Submit</button>
\\ <input type="email" placeholder="Email">
\\ <div>Not interactive</div>
\\ <div role="tab">Tab</div>
\\</div>
);
try testing.expectEqual(4, elements.len);
}

View File

@@ -60,6 +60,11 @@ fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context)
ctx.local = &self.local;
}
pub fn initFromHandle(self: *Caller, handle: ?*const v8.FunctionCallbackInfo) void {
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
self.init(isolate);
}
pub fn deinit(self: *Caller) void {
const ctx = self.local.ctx;
const call_depth = ctx.call_depth - 1;
@@ -441,6 +446,11 @@ pub const FunctionCallbackInfo = struct {
return .{ .local = local, .handle = v8.v8__FunctionCallbackInfo__INDEX(self.handle, @intCast(index)).? };
}
pub fn getData(self: FunctionCallbackInfo) ?*anyopaque {
const data = v8.v8__FunctionCallbackInfo__Data(self.handle) orelse return null;
return v8.v8__External__Value(@ptrCast(data));
}
pub fn getThis(self: FunctionCallbackInfo) *const v8.Object {
return v8.v8__FunctionCallbackInfo__This(self.handle).?;
}
@@ -499,6 +509,7 @@ pub const Function = struct {
as_typed_array: bool = false,
null_as_undefined: bool = false,
cache: ?Caching = null,
embedded_receiver: bool = false,
// 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
@@ -569,6 +580,9 @@ pub const Function = struct {
var args: ParameterTypes(F) = undefined;
if (comptime opts.static) {
args = try getArgs(F, 0, local, info);
} else if (comptime opts.embedded_receiver) {
args = try getArgs(F, 1, local, info);
@field(args, "0") = @ptrCast(@alignCast(info.getData() orelse unreachable));
} else {
args = try getArgs(F, 1, local, info);
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@@ -720,7 +734,7 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info:
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) {
if (slice_type == js.Value or (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)) = &.{};

View File

@@ -23,9 +23,11 @@ const log = @import("../../log.zig");
const js = @import("js.zig");
const Env = @import("Env.zig");
const bridge = @import("bridge.zig");
const Origin = @import("Origin.zig");
const Scheduler = @import("Scheduler.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const ScriptManager = @import("../ScriptManager.zig");
const v8 = js.v8;
@@ -41,6 +43,7 @@ const Context = @This();
id: usize,
env: *Env,
page: *Page,
session: *Session,
isolate: js.Isolate,
// Per-context microtask queue for isolation between contexts
@@ -74,39 +77,11 @@ call_depth: usize = 0,
// context.localScope
local: ?*const js.Local = null,
// Serves two purposes. Like `global_objects`, this is used to free
// every Global(Object) we've created during the lifetime of the context.
// More importantly, it serves as an identity map - for a given Zig
// instance, we map it to the same Global(Object).
// The key is the @intFromPtr of the Zig value
identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
origin: *Origin,
// Any type that is stored in the identity_map which has a finalizer declared
// will have its finalizer stored here. This is only used when shutting down
// if v8 hasn't called the finalizer directly itself.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
finalizer_callback_pool: std.heap.MemoryPool(FinalizerCallback),
// Some web APIs have to manage opaque values. Ideally, they use an
// js.Object, but the js.Object has no lifetime guarantee beyond the
// current call. They can call .persist() on their js.Object to get
// a `Global(Object)`. We need to track these to free them.
// This used to be a map and acted like identity_map; the key was
// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without
// a reliable way to know if an object has already been persisted,
// we now simply persist every time persist() is called.
global_values: std.ArrayList(v8.Global) = .empty,
global_objects: std.ArrayList(v8.Global) = .empty,
// Unlike other v8 types, like functions or objects, modules are not shared
// across origins.
global_modules: std.ArrayList(v8.Global) = .empty,
global_promises: std.ArrayList(v8.Global) = .empty,
global_functions: std.ArrayList(v8.Global) = .empty,
global_promise_resolvers: std.ArrayList(v8.Global) = .empty,
// Temp variants stored in HashMaps for O(1) early cleanup.
// Key is global.data_ptr.
global_values_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
global_promises_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
global_functions_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Our module cache: normalized module specifier => module.
module_cache: std.StringHashMapUnmanaged(ModuleEntry) = .empty,
@@ -153,7 +128,7 @@ pub fn fromIsolate(isolate: js.Isolate) *Context {
}
pub fn deinit(self: *Context) void {
if (comptime IS_DEBUG) {
if (comptime IS_DEBUG and @import("builtin").is_test == false) {
var it = self.unknown_properties.iterator();
while (it.next()) |kv| {
log.debug(.unknown_prop, "unknown property", .{
@@ -174,64 +149,11 @@ pub fn deinit(self: *Context) void {
// this can release objects
self.scheduler.deinit();
{
var it = self.identity_map.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
{
var it = self.finalizer_callbacks.valueIterator();
while (it.next()) |finalizer| {
finalizer.*.deinit();
}
self.finalizer_callback_pool.deinit();
}
for (self.global_values.items) |*global| {
v8.v8__Global__Reset(global);
}
for (self.global_objects.items) |*global| {
v8.v8__Global__Reset(global);
}
for (self.global_modules.items) |*global| {
v8.v8__Global__Reset(global);
}
for (self.global_functions.items) |*global| {
v8.v8__Global__Reset(global);
}
for (self.global_promises.items) |*global| {
v8.v8__Global__Reset(global);
}
for (self.global_promise_resolvers.items) |*global| {
v8.v8__Global__Reset(global);
}
{
var it = self.global_values_temp.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
{
var it = self.global_promises_temp.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
{
var it = self.global_functions_temp.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
self.session.releaseOrigin(self.origin);
v8.v8__Global__Reset(&self.handle);
env.isolate.notifyContextDisposed();
@@ -241,8 +163,40 @@ pub fn deinit(self: *Context) void {
v8.v8__MicrotaskQueue__DELETE(self.microtask_queue);
}
pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
const env = self.env;
const isolate = env.isolate;
const origin = try self.session.getOrCreateOrigin(key);
errdefer self.session.releaseOrigin(origin);
try self.origin.transferTo(origin);
self.origin.deinit(env.app);
self.origin = origin;
{
var ls: js.Local.Scope = undefined;
self.localScope(&ls);
defer ls.deinit();
// Set the V8::Context SecurityToken, which is a big part of what allows
// one context to access another.
const token_local = v8.v8__Global__Get(&origin.security_token, isolate.handle);
v8.v8__Context__SetSecurityToken(ls.local.handle, token_local);
}
}
pub fn trackGlobal(self: *Context, global: v8.Global) !void {
return self.origin.trackGlobal(global);
}
pub fn trackTemp(self: *Context, global: v8.Global) !void {
return self.origin.trackTemp(global);
}
pub fn weakRef(self: *Context, obj: anytype) void {
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
@@ -253,7 +207,7 @@ pub fn weakRef(self: *Context, obj: anytype) void {
}
pub fn safeWeakRef(self: *Context, obj: anytype) void {
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
@@ -265,7 +219,7 @@ pub fn safeWeakRef(self: *Context, obj: anytype) void {
}
pub fn strongRef(self: *Context, obj: anytype) void {
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
@@ -275,45 +229,6 @@ pub fn strongRef(self: *Context, obj: anytype) void {
v8.v8__Global__ClearWeak(&fc.global);
}
pub fn release(self: *Context, item: anytype) void {
if (@TypeOf(item) == *anyopaque) {
// Existing *anyopaque path for identity_map. Called internally from
// finalizers
var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
}
return;
};
v8.v8__Global__Reset(&global.value);
// The item has been fianalized, remove it for the finalizer callback so that
// we don't try to call it again on shutdown.
const fc = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
}
return;
};
self.finalizer_callback_pool.destroy(fc.value);
return;
}
var map = switch (@TypeOf(item)) {
js.Value.Temp => &self.global_values_temp,
js.Promise.Temp => &self.global_promises_temp,
js.Function.Temp => &self.global_functions_temp,
else => |T| @compileError("Context.release cannot be called with a " ++ @typeName(T)),
};
if (map.fetchRemove(item.handle.data_ptr)) |kv| {
var global = kv.value;
v8.v8__Global__Reset(&global);
}
}
// Any operation on the context have to be made from a local.
pub fn localScope(self: *Context, ls: *js.Local.Scope) void {
const isolate = self.isolate;
@@ -336,28 +251,18 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type
return l.toLocal(global);
}
// This isn't expected to be called often. It's for converting attributes into
// function calls, e.g. <body onload="doSomething"> will turn that "doSomething"
// string into a js.Function which looks like: function(e) { doSomething(e) }
// There might be more efficient ways to do this, but doing it this way means
// our code only has to worry about js.Funtion, not some union of a js.Function
// or a string.
pub fn stringToPersistedFunction(self: *Context, str: []const u8) !js.Function.Global {
pub fn stringToPersistedFunction(
self: *Context,
function_body: []const u8,
comptime parameter_names: []const []const u8,
extensions: []const v8.Object,
) !js.Function.Global {
var ls: js.Local.Scope = undefined;
self.localScope(&ls);
defer ls.deinit();
var extra: []const u8 = "";
const normalized = std.mem.trim(u8, str, &std.ascii.whitespace);
if (normalized.len > 0 and normalized[normalized.len - 1] != ')') {
extra = "(e)";
}
const full = try std.fmt.allocPrintSentinel(self.call_arena, "(function(e) {{ {s}{s} }})", .{ normalized, extra }, 0);
const js_val = try ls.local.compileAndRun(full, null);
if (!js_val.isFunction()) {
return error.StringFunctionError;
}
return try (js.Function{ .local = &ls.local, .handle = @ptrCast(js_val.handle) }).persist();
const js_function = try ls.local.compileFunction(function_body, parameter_names, extensions);
return js_function.persist();
}
pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local, src: []const u8, url: []const u8, cacheable: bool) !(if (want_result) ModuleEntry else void) {
@@ -535,6 +440,14 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: *
nested_gop.key_ptr.* = owned_specifier;
nested_gop.value_ptr.* = .{};
try script_manager.preloadImport(owned_specifier, url);
} else if (nested_gop.value_ptr.module == null) {
// Entry exists but module failed to compile previously.
// The imported_modules entry may have been consumed, so
// re-preload to ensure waitForImport can find it.
// Key was stored via dupeZ so it has a sentinel in memory.
const key = nested_gop.key_ptr.*;
const key_z: [:0]const u8 = key.ptr[0..key.len :0];
try script_manager.preloadImport(key_z, url);
}
}
}
@@ -683,7 +596,15 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co
return local.toLocal(m).handle;
}
var source = try self.script_manager.?.waitForImport(normalized_specifier);
var source = self.script_manager.?.waitForImport(normalized_specifier) catch |err| switch (err) {
error.UnknownModule => blk: {
// Module is in cache but was consumed from imported_modules
// (e.g., by a previous failed resolution). Re-preload and retry.
try self.script_manager.?.preloadImport(normalized_specifier, referrer_path);
break :blk try self.script_manager.?.waitForImport(normalized_specifier);
},
else => return err,
};
defer source.deinit();
var try_catch: js.TryCatch = undefined;
@@ -786,9 +707,16 @@ fn _dynamicModuleCallback(self: *Context, specifier: [:0]const u8, referrer: []c
entry.module_promise = try module_resolver.promise().persist();
} else {
// the module was loaded, but not evaluated, we _have_ to evaluate it now
if (status == .kUninstantiated) {
if (try mod.instantiate(resolveModuleCallback) == false) {
_ = resolver.reject("module instantiation", local.newString("Module instantiation failed"));
return promise;
}
}
const evaluated = mod.evaluate() catch {
if (comptime IS_DEBUG) {
std.debug.assert(status == .kErrored);
std.debug.assert(mod.getStatus() == .kErrored);
}
_ = resolver.reject("module evaluation", local.newString("Module evaluation failed"));
return promise;
@@ -868,13 +796,12 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul
const then_callback = newFunctionWithData(local, struct {
pub fn callback(callback_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(callback_handle).?;
var c: Caller = undefined;
c.init(isolate);
c.initFromHandle(callback_handle);
defer c.deinit();
const info_data = v8.v8__FunctionCallbackInfo__Data(callback_handle).?;
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(v8.v8__External__Value(@ptrCast(info_data))));
const info = Caller.FunctionCallbackInfo{ .handle = callback_handle.? };
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(info.getData() orelse return));
if (s.context_id != c.local.ctx.id) {
// The microtask is tied to the isolate, not the context
@@ -893,17 +820,15 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul
const catch_callback = newFunctionWithData(local, struct {
pub fn callback(callback_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(callback_handle).?;
var c: Caller = undefined;
c.init(isolate);
c.initFromHandle(callback_handle);
defer c.deinit();
const info_data = v8.v8__FunctionCallbackInfo__Data(callback_handle).?;
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(v8.v8__External__Value(@ptrCast(info_data))));
const info = Caller.FunctionCallbackInfo{ .handle = callback_handle.? };
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(info.getData() orelse return));
const l = &c.local;
const ctx = l.ctx;
if (s.context_id != ctx.id) {
if (s.context_id != l.ctx.id) {
return;
}
@@ -1007,39 +932,18 @@ fn enqueueMicrotask(self: *Context, callback: anytype) void {
}.run, self);
}
// There's an assumption here: the js.Function will be alive when microtasks are
// run. If we're Env.runMicrotasks in all the places that we're supposed to, then
// this should be safe (I think). In whatever HandleScope a microtask is enqueued,
// PerformCheckpoint should be run. So the v8::Local<v8::Function> should remain
// valid. If we have problems with this, a simple solution is to provide a Zig
// wrapper for these callbacks which references a js.Function.Temp, on callback
// it executes the function and then releases the global.
pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void {
// Use context-specific microtask queue instead of isolate queue
v8.v8__MicrotaskQueue__EnqueueMicrotaskFunc(self.microtask_queue, self.isolate.handle, cb.handle);
}
pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void) !*FinalizerCallback {
const fc = try self.finalizer_callback_pool.create();
fc.* = .{
.ctx = self,
.ptr = ptr,
.global = global,
.finalizerFn = finalizerFn,
};
return fc;
}
// == Misc ==
// A type that has a finalizer can have its finalizer called one of two ways.
// The first is from V8 via the WeakCallback we give to weakRef. But that isn't
// guaranteed to fire, so we track this in ctx._finalizers and call them on
// context shutdown.
pub const FinalizerCallback = struct {
ctx: *Context,
ptr: *anyopaque,
global: v8.Global,
finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void,
pub fn deinit(self: *FinalizerCallback) void {
self.finalizerFn(self.ptr, self.ctx.page);
self.ctx.finalizer_callback_pool.destroy(self);
}
};
// == Profiler ==
pub fn startCpuProfiler(self: *Context) void {
if (comptime !IS_DEBUG) {

View File

@@ -26,6 +26,7 @@ const App = @import("../../App.zig");
const log = @import("../../log.zig");
const bridge = @import("bridge.zig");
const Origin = @import("Origin.zig");
const Context = @import("Context.zig");
const Isolate = @import("Isolate.zig");
const Platform = @import("Platform.zig");
@@ -57,6 +58,8 @@ const Env = @This();
app: *App,
allocator: Allocator,
platform: *const Platform,
// the global isolate
@@ -70,6 +73,11 @@ isolate_params: *v8.CreateParams,
context_id: usize,
// Maps origin -> shared Origin contains, for v8 values shared across
// same-origin Contexts. There's a mismatch here between our JS model and our
// Browser model. Origins only live as long as the root page of a session exists.
// It would be wrong/dangerous to re-use an Origin across root page navigations.
// Global handles that need to be freed on deinit
eternal_function_templates: []v8.Eternal,
@@ -206,6 +214,7 @@ pub fn init(app: *App, opts: InitOpts) !Env {
return .{
.app = app,
.context_id = 0,
.allocator = allocator,
.contexts = undefined,
.context_count = 0,
.isolate = isolate,
@@ -228,7 +237,9 @@ pub fn deinit(self: *Env) void {
ctx.deinit();
}
const allocator = self.app.allocator;
const app = self.app;
const allocator = app.allocator;
if (self.inspector) |i| {
i.deinit(allocator);
}
@@ -272,6 +283,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
// 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
@@ -287,6 +299,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
};
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);
@@ -294,10 +307,15 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
const context_id = self.context_id;
self.context_id = context_id + 1;
const origin = try page._session.getOrCreateOrigin(null);
errdefer page._session.releaseOrigin(origin);
const context = try context_arena.create(Context);
context.* = .{
.env = self,
.page = page,
.session = page._session,
.origin = origin,
.id = context_id,
.isolate = isolate,
.arena = context_arena,
@@ -307,9 +325,8 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
.microtask_queue = microtask_queue,
.script_manager = &page._script_manager,
.scheduler = .init(context_arena),
.finalizer_callback_pool = std.heap.MemoryPool(Context.FinalizerCallback).init(self.app.allocator),
};
try context.identity_map.putNoClobber(context_arena, @intFromPtr(page.window), global_global);
try context.origin.identity_map.putNoClobber(context_arena, @intFromPtr(page.window), global_global);
// Store a pointer to our context inside the v8 context so that, given
// a v8 context, we can get our context out
@@ -470,6 +487,10 @@ pub fn dumpMemoryStats(self: *Env) void {
, .{ stats.total_heap_size, stats.total_heap_size_executable, stats.total_physical_size, stats.total_available_size, stats.used_heap_size, stats.heap_size_limit, stats.malloced_memory, stats.external_memory, stats.peak_malloced_memory, stats.number_of_native_contexts, stats.number_of_detached_contexts, stats.total_global_handles_size, stats.used_global_handles_size, stats.does_zap_garbage });
}
pub fn terminate(self: *const Env) void {
v8.v8__Isolate__TerminateExecution(self.isolate.handle);
}
fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) void {
const promise_handle = v8.v8__PromiseRejectMessage__GetPromise(&message_handle).?;
const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?;

View File

@@ -160,8 +160,8 @@ fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args
try_catch.rethrow();
return error.TryCatchRethrow;
}
caught.* = try_catch.caughtOrError(local.call_arena, error.JSExecCallback);
return error.JSExecCallback;
caught.* = try_catch.caughtOrError(local.call_arena, error.JsException);
return error.JsException;
};
if (@typeInfo(T) == .void) {
@@ -209,11 +209,11 @@ fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Gl
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.global_functions.append(ctx.arena, global);
} else {
try ctx.global_functions_temp.put(ctx.arena, global.data_ptr, global);
try ctx.trackGlobal(global);
return .{ .handle = global, .origin = {} };
}
return .{ .handle = global };
try ctx.trackTemp(global);
return .{ .handle = global, .origin = ctx.origin };
}
pub fn tempWithThis(self: *const Function, value: anytype) !Temp {
@@ -226,15 +226,18 @@ pub fn persistWithThis(self: *const Function, value: anytype) !Global {
return with_this.persist();
}
pub const Temp = G(0);
pub const Global = G(1);
pub const Temp = G(.temp);
pub const Global = G(.global);
fn G(comptime discriminator: u8) type {
const GlobalType = enum(u8) {
temp,
global,
};
fn G(comptime global_type: GlobalType) type {
return struct {
handle: v8.Global,
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
origin: if (global_type == .temp) *js.Origin else void,
const Self = @This();
@@ -252,5 +255,9 @@ fn G(comptime discriminator: u8) type {
pub fn isEqual(self: *const Self, other: Function) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
pub fn release(self: *const Self) void {
self.origin.releaseTemp(self.handle);
}
};
}

View File

@@ -130,6 +130,12 @@ pub fn contextCreated(
pub fn contextDestroyed(self: *Inspector, context: *const v8.Context) void {
v8.v8_inspector__Inspector__ContextDestroyed(self.handle, context);
if (self.default_context) |*dc| {
if (v8.v8__Global__IsEqual(dc, context)) {
self.default_context = null;
}
}
}
pub fn resetContextGroup(self: *const Inspector) void {

View File

@@ -18,6 +18,7 @@
const std = @import("std");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const log = @import("../../log.zig");
const string = @import("../../string.zig");
@@ -82,6 +83,20 @@ pub fn createTypedArray(self: *const Local, comptime array_type: js.ArrayType, s
return .init(self, size);
}
pub fn newCallback(
self: *const Local,
callback: anytype,
data: anytype,
) js.Function {
const external = self.isolate.createExternal(data);
const handle = v8.v8__Function__New__DEFAULT2(self.handle, struct {
fn wrap(info_handle: ?*const js.v8.FunctionCallbackInfo) callconv(.c) void {
Caller.Function.call(@TypeOf(data), info_handle.?, callback, .{ .embedded_receiver = true });
}
}.wrap, @ptrCast(external)).?;
return .{ .local = self, .handle = handle };
}
pub fn runMacrotasks(self: *const Local) void {
const env = self.ctx.env;
env.pumpMessageLoop();
@@ -101,6 +116,49 @@ pub fn exec(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value {
return self.compileAndRun(src, name);
}
/// Compiles a function body as function.
///
/// https://v8.github.io/api/head/classv8_1_1ScriptCompiler.html#a3a15bb5a7dfc3f998e6ac789e6b4646a
pub fn compileFunction(
self: *const Local,
function_body: []const u8,
/// We tend to know how many params we'll pass; can remove the comptime if necessary.
comptime parameter_names: []const []const u8,
extensions: []const v8.Object,
) !js.Function {
// TODO: Make configurable.
const script_name = self.isolate.initStringHandle("anonymous");
const script_source = self.isolate.initStringHandle(function_body);
var parameter_list: [parameter_names.len]*const v8.String = undefined;
inline for (0..parameter_names.len) |i| {
parameter_list[i] = self.isolate.initStringHandle(parameter_names[i]);
}
// Create `ScriptOrigin`.
var origin: v8.ScriptOrigin = undefined;
v8.v8__ScriptOrigin__CONSTRUCT(&origin, script_name);
// Create `ScriptCompilerSource`.
var script_compiler_source: v8.ScriptCompilerSource = undefined;
v8.v8__ScriptCompiler__Source__CONSTRUCT2(script_source, &origin, null, &script_compiler_source);
defer v8.v8__ScriptCompiler__Source__DESTRUCT(&script_compiler_source);
// Compile the function.
const result = v8.v8__ScriptCompiler__CompileFunction(
self.handle,
&script_compiler_source,
parameter_list.len,
&parameter_list,
extensions.len,
@ptrCast(&extensions),
v8.kNoCompileOptions,
v8.kNoCacheNoReason,
) orelse return error.CompilationError;
return .{ .local = self, .handle = result };
}
pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value {
const script_name = self.isolate.initStringHandle(name orelse "anonymous");
const script_source = self.isolate.initStringHandle(src);
@@ -123,7 +181,7 @@ pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js
) orelse return error.CompilationError;
// Run the script
const result = v8.v8__Script__Run(v8_script, self.handle) orelse return error.ExecutionError;
const result = v8.v8__Script__Run(v8_script, self.handle) orelse return error.JsException;
return .{ .local = self, .handle = result };
}
@@ -157,7 +215,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
.pointer => |ptr| {
const resolved = resolveValue(value);
const gop = try ctx.identity_map.getOrPut(arena, @intFromPtr(resolved.ptr));
const gop = try ctx.origin.identity_map.getOrPut(arena, @intFromPtr(resolved.ptr));
if (gop.found_existing) {
// we've seen this instance before, return the same object
return (js.Object.Global{ .handle = gop.value_ptr.* }).local(self);
@@ -211,16 +269,17 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
// can't figure out how to make that work, since it depends on
// the [runtime] `value`.
// We need the resolved finalizer, which we have in resolved.
//
// The above if statement would be more clear as:
// if (resolved.finalizer_from_v8) |finalizer| {
// But that's a runtime check.
// Instead, we check if the base has finalizer. The assumption
// here is that if a resolve type has a finalizer, then the base
// should have a finalizer too.
const fc = try ctx.createFinalizerCallback(gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?);
const fc = try ctx.origin.createFinalizerCallback(ctx.session, gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?);
{
errdefer fc.deinit();
try ctx.finalizer_callbacks.put(ctx.arena, @intFromPtr(resolved.ptr), fc);
try ctx.origin.finalizer_callbacks.put(ctx.origin.arena, @intFromPtr(resolved.ptr), fc);
}
conditionallyReference(value);
@@ -1069,7 +1128,7 @@ const Resolved = struct {
class_id: u16,
prototype_chain: []const @import("TaggedOpaque.zig").PrototypeChainEntry,
finalizer_from_v8: ?*const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void = null,
finalizer_from_zig: ?*const fn (ptr: *anyopaque, page: *Page) void = null,
finalizer_from_zig: ?*const fn (ptr: *anyopaque, session: *Session) void = null,
};
pub fn resolveValue(value: anytype) Resolved {
const T = bridge.Struct(@TypeOf(value));

View File

@@ -97,7 +97,7 @@ pub fn persist(self: Object) !Global {
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.global_objects.append(ctx.arena, global);
try ctx.trackGlobal(global);
return .{ .handle = global };
}

240
src/browser/js/Origin.zig Normal file
View File

@@ -0,0 +1,240 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Origin represents the shared Zig<->JS bridge state for all contexts within
// the same origin. Multiple contexts (frames) from the same origin share a
// single Origin, ensuring that JS objects maintain their identity across frames.
const std = @import("std");
const js = @import("js.zig");
const App = @import("../../App.zig");
const Session = @import("../Session.zig");
const v8 = js.v8;
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Origin = @This();
rc: usize = 1,
arena: Allocator,
// The key, e.g. lightpanda.io:443
key: []const u8,
// Security token - all contexts in this realm must use the same v8::Value instance
// as their security token for V8 to allow cross-context access
security_token: v8.Global,
// Serves two purposes. Like `global_objects`, this is used to free
// every Global(Object) we've created during the lifetime of the realm.
// More importantly, it serves as an identity map - for a given Zig
// instance, we map it to the same Global(Object).
// The key is the @intFromPtr of the Zig value
identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Some web APIs have to manage opaque values. Ideally, they use an
// js.Object, but the js.Object has no lifetime guarantee beyond the
// current call. They can call .persist() on their js.Object to get
// a `Global(Object)`. We need to track these to free them.
// This used to be a map and acted like identity_map; the key was
// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without
// a reliable way to know if an object has already been persisted,
// we now simply persist every time persist() is called.
globals: std.ArrayList(v8.Global) = .empty,
// Temp variants stored in HashMaps for O(1) early cleanup.
// Key is global.data_ptr.
temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Any type that is stored in the identity_map which has a finalizer declared
// will have its finalizer stored here. This is only used when shutting down
// if v8 hasn't called the finalizer directly itself.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
const arena = try app.arena_pool.acquire();
errdefer app.arena_pool.release(arena);
var hs: js.HandleScope = undefined;
hs.init(isolate);
defer hs.deinit();
const owned_key = try arena.dupe(u8, key);
const token_local = isolate.initStringHandle(owned_key);
var token_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, token_local, &token_global);
const self = try arena.create(Origin);
self.* = .{
.rc = 1,
.arena = arena,
.key = owned_key,
.globals = .empty,
.temps = .empty,
.security_token = token_global,
};
return self;
}
pub fn deinit(self: *Origin, app: *App) void {
// Call finalizers before releasing anything
{
var it = self.finalizer_callbacks.valueIterator();
while (it.next()) |finalizer| {
finalizer.*.deinit();
}
}
v8.v8__Global__Reset(&self.security_token);
{
var it = self.identity_map.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
for (self.globals.items) |*global| {
v8.v8__Global__Reset(global);
}
{
var it = self.temps.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
app.arena_pool.release(self.arena);
}
pub fn trackGlobal(self: *Origin, global: v8.Global) !void {
return self.globals.append(self.arena, global);
}
pub fn trackTemp(self: *Origin, global: v8.Global) !void {
return self.temps.put(self.arena, global.data_ptr, global);
}
pub fn releaseTemp(self: *Origin, global: v8.Global) void {
if (self.temps.fetchRemove(global.data_ptr)) |kv| {
var g = kv.value;
v8.v8__Global__Reset(&g);
}
}
/// Release an item from the identity_map (called after finalizer runs from V8)
pub fn release(self: *Origin, item: *anyopaque) void {
var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
std.debug.assert(false);
}
return;
};
v8.v8__Global__Reset(&global.value);
// The item has been finalized, remove it from the finalizer callback so that
// we don't try to call it again on shutdown.
const kv = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
std.debug.assert(false);
}
return;
};
const fc = kv.value;
fc.session.releaseArena(fc.arena);
}
pub fn createFinalizerCallback(
self: *Origin,
session: *Session,
global: v8.Global,
ptr: *anyopaque,
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
) !*FinalizerCallback {
const arena = try session.getArena(.{ .debug = "FinalizerCallback" });
errdefer session.releaseArena(arena);
const fc = try arena.create(FinalizerCallback);
fc.* = .{
.arena = arena,
.origin = self,
.session = session,
.ptr = ptr,
.global = global,
.zig_finalizer = zig_finalizer,
};
return fc;
}
pub fn transferTo(self: *Origin, dest: *Origin) !void {
const arena = dest.arena;
try dest.globals.ensureUnusedCapacity(arena, self.globals.items.len);
for (self.globals.items) |obj| {
dest.globals.appendAssumeCapacity(obj);
}
self.globals.clearRetainingCapacity();
{
try dest.temps.ensureUnusedCapacity(arena, self.temps.count());
var it = self.temps.iterator();
while (it.next()) |kv| {
try dest.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
self.temps.clearRetainingCapacity();
}
{
try dest.finalizer_callbacks.ensureUnusedCapacity(arena, self.finalizer_callbacks.count());
var it = self.finalizer_callbacks.iterator();
while (it.next()) |kv| {
kv.value_ptr.*.origin = dest;
try dest.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
self.finalizer_callbacks.clearRetainingCapacity();
}
{
try dest.identity_map.ensureUnusedCapacity(arena, self.identity_map.count());
var it = self.identity_map.iterator();
while (it.next()) |kv| {
try dest.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
self.identity_map.clearRetainingCapacity();
}
}
// A type that has a finalizer can have its finalizer called one of two ways.
// The first is from V8 via the WeakCallback we give to weakRef. But that isn't
// guaranteed to fire, so we track this in finalizer_callbacks and call them on
// origin shutdown.
pub const FinalizerCallback = struct {
arena: Allocator,
origin: *Origin,
session: *Session,
ptr: *anyopaque,
global: v8.Global,
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
pub fn deinit(self: *FinalizerCallback) void {
self.zig_finalizer(self.ptr, self.session);
self.session.releaseArena(self.arena);
}
};

View File

@@ -62,22 +62,25 @@ fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Glo
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.global_promises.append(ctx.arena, global);
} else {
try ctx.global_promises_temp.put(ctx.arena, global.data_ptr, global);
try ctx.trackGlobal(global);
return .{ .handle = global, .origin = {} };
}
return .{ .handle = global };
try ctx.trackTemp(global);
return .{ .handle = global, .origin = ctx.origin };
}
pub const Temp = G(0);
pub const Global = G(1);
pub const Temp = G(.temp);
pub const Global = G(.global);
fn G(comptime discriminator: u8) type {
const GlobalType = enum(u8) {
temp,
global,
};
fn G(comptime global_type: GlobalType) type {
return struct {
handle: v8.Global,
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
origin: if (global_type == .temp) *js.Origin else void,
const Self = @This();
@@ -91,5 +94,9 @@ fn G(comptime discriminator: u8) type {
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
pub fn release(self: *const Self) void {
self.origin.releaseTemp(self.handle);
}
};
}

View File

@@ -79,7 +79,7 @@ pub fn persist(self: PromiseResolver) !Global {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.global_promise_resolvers.append(ctx.arena, global);
try ctx.trackGlobal(global);
return .{ .handle = global };
}

View File

@@ -259,11 +259,11 @@ fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Globa
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.global_values.append(ctx.arena, global);
} else {
try ctx.global_values_temp.put(ctx.arena, global.data_ptr, global);
try ctx.trackGlobal(global);
return .{ .handle = global, .origin = {} };
}
return .{ .handle = global };
try ctx.trackTemp(global);
return .{ .handle = global, .origin = ctx.origin };
}
pub fn toZig(self: Value, comptime T: type) !T {
@@ -310,15 +310,18 @@ pub fn format(self: Value, writer: *std.Io.Writer) !void {
return js_str.format(writer);
}
pub const Temp = G(0);
pub const Global = G(1);
pub const Temp = G(.temp);
pub const Global = G(.global);
fn G(comptime discriminator: u8) type {
const GlobalType = enum(u8) {
temp,
global,
};
fn G(comptime global_type: GlobalType) type {
return struct {
handle: v8.Global,
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
origin: if (global_type == .temp) *js.Origin else void,
const Self = @This();
@@ -336,5 +339,9 @@ fn G(comptime discriminator: u8) type {
pub fn isEqual(self: *const Self, other: Value) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
pub fn release(self: *const Self) void {
self.origin.releaseTemp(self.handle);
}
};
}

View File

@@ -21,11 +21,13 @@ const js = @import("js.zig");
const lp = @import("lightpanda");
const log = @import("../../log.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const v8 = js.v8;
const Caller = @import("Caller.zig");
const Context = @import("Context.zig");
const Origin = @import("Origin.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -104,24 +106,24 @@ pub fn Builder(comptime T: type) type {
return entries;
}
pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, page: *Page) void) Finalizer {
pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, session: *Session) void) Finalizer {
return .{
.from_zig = struct {
fn wrap(ptr: *anyopaque, page: *Page) void {
func(@ptrCast(@alignCast(ptr)), true, page);
fn wrap(ptr: *anyopaque, session: *Session) void {
func(@ptrCast(@alignCast(ptr)), true, session);
}
}.wrap,
.from_v8 = struct {
fn wrap(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void {
const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?;
const fc: *Context.FinalizerCallback = @ptrCast(@alignCast(ptr));
const fc: *Origin.FinalizerCallback = @ptrCast(@alignCast(ptr));
const ctx = fc.ctx;
const origin = fc.origin;
const value_ptr = fc.ptr;
if (ctx.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
func(@ptrCast(@alignCast(value_ptr)), false, ctx.page);
ctx.release(value_ptr);
if (origin.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
func(@ptrCast(@alignCast(value_ptr)), false, fc.session);
origin.release(value_ptr);
} else {
// A bit weird, but v8 _requires_ that we release it
// If we don't. We'll 100% crash.
@@ -413,12 +415,12 @@ pub const Property = struct {
};
const Finalizer = struct {
// The finalizer wrapper when called fro Zig. This is only called on
// Context.deinit
from_zig: *const fn (ctx: *anyopaque, page: *Page) void,
// The finalizer wrapper when called from Zig. This is only called on
// Origin.deinit
from_zig: *const fn (ctx: *anyopaque, session: *Session) void,
// The finalizer wrapper when called from V8. This may never be called
// (hence why we fallback to calling in Context.denit). If it is called,
// (hence why we fallback to calling in Origin.deinit). If it is called,
// it is only ever called after we SetWeak on the Global.
from_v8: *const fn (?*const v8.WeakCallbackInfo) callconv(.c) void,
};
@@ -730,6 +732,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/css/CSSStyleRule.zig"),
@import("../webapi/css/CSSStyleSheet.zig"),
@import("../webapi/css/CSSStyleProperties.zig"),
@import("../webapi/css/FontFace.zig"),
@import("../webapi/css/FontFaceSet.zig"),
@import("../webapi/css/MediaQueryList.zig"),
@import("../webapi/css/StyleSheetList.zig"),
@@ -767,6 +770,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/element/html/Custom.zig"),
@import("../webapi/element/html/Data.zig"),
@import("../webapi/element/html/DataList.zig"),
@import("../webapi/element/html/Details.zig"),
@import("../webapi/element/html/Dialog.zig"),
@import("../webapi/element/html/Directory.zig"),
@import("../webapi/element/html/DList.zig"),
@@ -826,6 +830,8 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/element/svg/Generic.zig"),
@import("../webapi/encoding/TextDecoder.zig"),
@import("../webapi/encoding/TextEncoder.zig"),
@import("../webapi/encoding/TextEncoderStream.zig"),
@import("../webapi/encoding/TextDecoderStream.zig"),
@import("../webapi/Event.zig"),
@import("../webapi/event/CompositionEvent.zig"),
@import("../webapi/event/CustomEvent.zig"),
@@ -862,6 +868,10 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/streams/ReadableStream.zig"),
@import("../webapi/streams/ReadableStreamDefaultReader.zig"),
@import("../webapi/streams/ReadableStreamDefaultController.zig"),
@import("../webapi/streams/WritableStream.zig"),
@import("../webapi/streams/WritableStreamDefaultWriter.zig"),
@import("../webapi/streams/WritableStreamDefaultController.zig"),
@import("../webapi/streams/TransformStream.zig"),
@import("../webapi/Node.zig"),
@import("../webapi/storage/storage.zig"),
@import("../webapi/URL.zig"),

View File

@@ -24,6 +24,7 @@ const string = @import("../../string.zig");
pub const Env = @import("Env.zig");
pub const bridge = @import("bridge.zig");
pub const Caller = @import("Caller.zig");
pub const Origin = @import("Origin.zig");
pub const Context = @import("Context.zig");
pub const Local = @import("Local.zig");
pub const Inspector = @import("Inspector.zig");
@@ -161,7 +162,7 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type {
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);
try ctx.trackGlobal(global);
return .{ .handle = global };
}

View File

@@ -19,6 +19,8 @@
const std = @import("std");
const Page = @import("Page.zig");
const URL = @import("URL.zig");
const TreeWalker = @import("webapi/TreeWalker.zig");
const CData = @import("webapi/CData.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
@@ -103,20 +105,37 @@ fn isVisibleElement(el: *Element) bool {
};
}
fn getAnchorLabel(el: *Element) ?[]const u8 {
return el.getAttributeSafe(comptime .wrap("aria-label")) orelse el.getAttributeSafe(comptime .wrap("title"));
}
fn isAllWhitespace(text: []const u8) bool {
return for (text) |c| {
if (!std.ascii.isWhitespace(c)) break false;
} else true;
}
fn hasBlockDescendant(node: *Node) bool {
var it = node.childrenIterator();
return while (it.next()) |child| {
if (child.is(Element)) |el| {
if (isBlock(el.getTag())) break true;
if (hasBlockDescendant(child)) break true;
fn hasBlockDescendant(root: *Node) bool {
var tw = TreeWalker.FullExcludeSelf.Elements.init(root, .{});
while (tw.next()) |el| {
if (isBlock(el.getTag())) return true;
}
return false;
}
fn hasVisibleContent(root: *Node) bool {
var tw = TreeWalker.FullExcludeSelf.init(root, .{});
while (tw.next()) |node| {
if (isSignificantText(node)) return true;
if (node.is(Element)) |el| {
if (!isVisibleElement(el)) {
tw.skipChildren();
} else if (el.getTag() == .img) {
return true;
}
}
} else false;
}
return false;
}
fn ensureNewline(state: *State, writer: *std.Io.Writer) !void {
@@ -278,20 +297,29 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag
}
try writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
try writer.writeAll(src);
const absolute_src = URL.resolve(page.call_arena, page.base(), src, .{ .encode = true }) catch src;
try writer.writeAll(absolute_src);
}
try writer.writeAll(")");
state.last_char_was_newline = false;
return;
},
.anchor => {
const has_content = hasVisibleContent(el.asNode());
const label = getAnchorLabel(el);
const href_raw = el.getAttributeSafe(comptime .wrap("href"));
if (!has_content and label == null and href_raw == null) return;
const has_block = hasBlockDescendant(el.asNode());
const href = if (href_raw) |h| URL.resolve(page.call_arena, page.base(), h, .{ .encode = true }) catch h else null;
if (has_block) {
try renderChildren(el.asNode(), state, writer, page);
if (el.getAttributeSafe(comptime .wrap("href"))) |href| {
if (href) |h| {
if (!state.last_char_was_newline) try writer.writeByte('\n');
try writer.writeAll("([Link](");
try writer.writeAll(href);
try writer.writeAll("([](");
try writer.writeAll(h);
try writer.writeAll("))\n");
state.last_char_was_newline = true;
}
@@ -301,10 +329,14 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag
if (isStandaloneAnchor(el)) {
if (!state.last_char_was_newline) try writer.writeByte('\n');
try writer.writeByte('[');
try renderChildren(el.asNode(), state, writer, page);
if (has_content) {
try renderChildren(el.asNode(), state, writer, page);
} else {
try writer.writeAll(label orelse "");
}
try writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("href"))) |href| {
try writer.writeAll(href);
if (href) |h| {
try writer.writeAll(h);
}
try writer.writeAll(")\n");
state.last_char_was_newline = true;
@@ -312,10 +344,14 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag
}
try writer.writeByte('[');
try renderChildren(el.asNode(), state, writer, page);
if (has_content) {
try renderChildren(el.asNode(), state, writer, page);
} else {
try writer.writeAll(label orelse "");
}
try writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("href"))) |href| {
try writer.writeAll(href);
if (href) |h| {
try writer.writeAll(h);
}
try writer.writeByte(')');
state.last_char_was_newline = false;
@@ -452,6 +488,8 @@ 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();
page.url = "http://localhost/";
const doc = page.window._document;
const div = try doc.createElement("div", null, page);
@@ -520,11 +558,11 @@ test "browser.markdown: blockquote" {
}
test "browser.markdown: links" {
try testMarkdownHTML("<a href=\"https://lightpanda.io\">Lightpanda</a>", "[Lightpanda](https://lightpanda.io)\n");
try testMarkdownHTML("<a href=\"/relative\">Link</a>", "[Link](http://localhost/relative)\n");
}
test "browser.markdown: images" {
try testMarkdownHTML("<img src=\"logo.png\" alt=\"Logo\">", "![Logo](logo.png)\n");
try testMarkdownHTML("<img src=\"logo.png\" alt=\"Logo\">", "![Logo](http://localhost/logo.png)\n");
}
test "browser.markdown: headings" {
@@ -565,7 +603,7 @@ test "browser.markdown: block link" {
\\### Title
\\
\\Description
\\([Link](https://example.com))
\\([](https://example.com))
\\
);
}
@@ -588,8 +626,8 @@ test "browser.markdown: standalone anchors" {
\\ <a href="2">Link 2</a>
\\</main>
,
\\[Link 1](1)
\\[Link 2](2)
\\[Link 1](http://localhost/1)
\\[Link 2](http://localhost/2)
\\
);
}
@@ -601,7 +639,58 @@ test "browser.markdown: mixed anchors in main" {
\\ Welcome <a href="1">Link 1</a>.
\\</main>
,
\\Welcome [Link 1](1).
\\Welcome [Link 1](http://localhost/1).
\\
);
}
test "browser.markdown: skip empty links" {
try testMarkdownHTML(
\\<a href="/"></a>
\\<a href="/"><svg></svg></a>
,
\\[](http://localhost/)
\\[](http://localhost/)
\\
);
}
test "browser.markdown: resolve links" {
const testing = @import("../testing.zig");
const page = try testing.test_session.createPage();
defer testing.test_session.removePage();
page.url = "https://example.com/a/index.html";
const doc = page.window._document;
const div = try doc.createElement("div", null, page);
try page.parseHtmlAsChildren(div.asNode(),
\\<a href="b">Link</a>
\\<img src="../c.png" alt="Img">
\\<a href="/my page">Space</a>
);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try dump(div.asNode(), .{}, &aw.writer, page);
try testing.expectString(
\\[Link](https://example.com/a/b)
\\![Img](https://example.com/c.png)
\\[Space](https://example.com/my%20page)
\\
, aw.written());
}
test "browser.markdown: anchor fallback label" {
try testMarkdownHTML(
\\<a href="/discord" aria-label="Discord Server"><svg></svg></a>
, "[Discord Server](http://localhost/discord)\n");
try testMarkdownHTML(
\\<a href="/search" title="Search Site"><svg></svg></a>
, "[Search Site](http://localhost/search)\n");
try testMarkdownHTML(
\\<a href="/no-label"><svg></svg></a>
, "[](http://localhost/no-label)\n");
}

View File

@@ -0,0 +1,489 @@
// 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 URL = @import("URL.zig");
const TreeWalker = @import("webapi/TreeWalker.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const Allocator = std.mem.Allocator;
/// Key-value pair for structured data properties.
pub const Property = struct {
key: []const u8,
value: []const u8,
};
pub const AlternateLink = struct {
href: []const u8,
hreflang: ?[]const u8,
type: ?[]const u8,
title: ?[]const u8,
};
pub const StructuredData = struct {
json_ld: []const []const u8,
open_graph: []const Property,
twitter_card: []const Property,
meta: []const Property,
links: []const Property,
alternate: []const AlternateLink,
pub fn jsonStringify(self: *const StructuredData, jw: anytype) !void {
try jw.beginObject();
try jw.objectField("jsonLd");
try jw.write(self.json_ld);
try jw.objectField("openGraph");
try writeProperties(jw, self.open_graph);
try jw.objectField("twitterCard");
try writeProperties(jw, self.twitter_card);
try jw.objectField("meta");
try writeProperties(jw, self.meta);
try jw.objectField("links");
try writeProperties(jw, self.links);
if (self.alternate.len > 0) {
try jw.objectField("alternate");
try jw.beginArray();
for (self.alternate) |alt| {
try jw.beginObject();
try jw.objectField("href");
try jw.write(alt.href);
if (alt.hreflang) |v| {
try jw.objectField("hreflang");
try jw.write(v);
}
if (alt.type) |v| {
try jw.objectField("type");
try jw.write(v);
}
if (alt.title) |v| {
try jw.objectField("title");
try jw.write(v);
}
try jw.endObject();
}
try jw.endArray();
}
try jw.endObject();
}
};
/// Serializes properties as a JSON object. When a key appears multiple times
/// (e.g. multiple og:image tags), values are grouped into an array.
/// Alternatives considered: always-array values (verbose), or an array of
/// {key, value} pairs (preserves order but less ergonomic for consumers).
fn writeProperties(jw: anytype, properties: []const Property) !void {
try jw.beginObject();
for (properties, 0..) |prop, i| {
// Skip keys already written by an earlier occurrence.
var already_written = false;
for (properties[0..i]) |prev| {
if (std.mem.eql(u8, prev.key, prop.key)) {
already_written = true;
break;
}
}
if (already_written) continue;
// Count total occurrences to decide string vs array.
var count: usize = 0;
for (properties) |p| {
if (std.mem.eql(u8, p.key, prop.key)) count += 1;
}
try jw.objectField(prop.key);
if (count == 1) {
try jw.write(prop.value);
} else {
try jw.beginArray();
for (properties) |p| {
if (std.mem.eql(u8, p.key, prop.key)) {
try jw.write(p.value);
}
}
try jw.endArray();
}
}
try jw.endObject();
}
/// Extract all structured data from the page.
pub fn collectStructuredData(
root: *Node,
arena: Allocator,
page: *Page,
) !StructuredData {
var json_ld: std.ArrayList([]const u8) = .empty;
var open_graph: std.ArrayList(Property) = .empty;
var twitter_card: std.ArrayList(Property) = .empty;
var meta: std.ArrayList(Property) = .empty;
var links: std.ArrayList(Property) = .empty;
var alternate: std.ArrayList(AlternateLink) = .empty;
// Extract language from the root <html> element.
if (root.is(Element)) |root_el| {
if (root_el.getAttributeSafe(comptime .wrap("lang"))) |lang| {
try meta.append(arena, .{ .key = "language", .value = lang });
}
} else {
// Root is document — check documentElement.
var children = root.childrenIterator();
while (children.next()) |child| {
const el = child.is(Element) orelse continue;
if (el.getTag() == .html) {
if (el.getAttributeSafe(comptime .wrap("lang"))) |lang| {
try meta.append(arena, .{ .key = "language", .value = lang });
}
break;
}
}
}
var tw = TreeWalker.Full.init(root, .{});
while (tw.next()) |node| {
const el = node.is(Element) orelse continue;
switch (el.getTag()) {
.script => {
try collectJsonLd(el, arena, &json_ld);
tw.skipChildren();
},
.meta => collectMeta(el, &open_graph, &twitter_card, &meta, arena) catch {},
.title => try collectTitle(node, arena, &meta),
.link => try collectLink(el, arena, page, &links, &alternate),
// Skip body subtree for non-JSON-LD — all other metadata is in <head>.
// JSON-LD can appear in <body> so we don't skip the whole body.
else => {},
}
}
return .{
.json_ld = json_ld.items,
.open_graph = open_graph.items,
.twitter_card = twitter_card.items,
.meta = meta.items,
.links = links.items,
.alternate = alternate.items,
};
}
fn collectJsonLd(
el: *Element,
arena: Allocator,
json_ld: *std.ArrayList([]const u8),
) !void {
const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return;
if (!std.ascii.eqlIgnoreCase(type_attr, "application/ld+json")) return;
var buf: std.Io.Writer.Allocating = .init(arena);
try el.asNode().getTextContent(&buf.writer);
const text = buf.written();
if (text.len > 0) {
try json_ld.append(arena, std.mem.trim(u8, text, &std.ascii.whitespace));
}
}
fn collectMeta(
el: *Element,
open_graph: *std.ArrayList(Property),
twitter_card: *std.ArrayList(Property),
meta: *std.ArrayList(Property),
arena: Allocator,
) !void {
// charset: <meta charset="..."> (no content attribute needed).
if (el.getAttributeSafe(comptime .wrap("charset"))) |charset| {
try meta.append(arena, .{ .key = "charset", .value = charset });
}
const content = el.getAttributeSafe(comptime .wrap("content")) orelse return;
// Open Graph: <meta property="og:...">
if (el.getAttributeSafe(comptime .wrap("property"))) |property| {
if (std.mem.startsWith(u8, property, "og:")) {
try open_graph.append(arena, .{ .key = property[3..], .value = content });
return;
}
// Article, profile, etc. are OG sub-namespaces.
if (std.mem.startsWith(u8, property, "article:") or
std.mem.startsWith(u8, property, "profile:") or
std.mem.startsWith(u8, property, "book:") or
std.mem.startsWith(u8, property, "music:") or
std.mem.startsWith(u8, property, "video:"))
{
try open_graph.append(arena, .{ .key = property, .value = content });
return;
}
}
// Twitter Cards: <meta name="twitter:...">
if (el.getAttributeSafe(comptime .wrap("name"))) |name| {
if (std.mem.startsWith(u8, name, "twitter:")) {
try twitter_card.append(arena, .{ .key = name[8..], .value = content });
return;
}
// Standard meta tags by name.
const known_names = [_][]const u8{
"description", "author", "keywords", "robots",
"viewport", "generator", "theme-color",
};
for (known_names) |known| {
if (std.ascii.eqlIgnoreCase(name, known)) {
try meta.append(arena, .{ .key = known, .value = content });
return;
}
}
}
// http-equiv (e.g. Content-Type, refresh)
if (el.getAttributeSafe(comptime .wrap("http-equiv"))) |http_equiv| {
try meta.append(arena, .{ .key = http_equiv, .value = content });
}
}
fn collectTitle(
node: *Node,
arena: Allocator,
meta: *std.ArrayList(Property),
) !void {
var buf: std.Io.Writer.Allocating = .init(arena);
try node.getTextContent(&buf.writer);
const text = std.mem.trim(u8, buf.written(), &std.ascii.whitespace);
if (text.len > 0) {
try meta.append(arena, .{ .key = "title", .value = text });
}
}
fn collectLink(
el: *Element,
arena: Allocator,
page: *Page,
links: *std.ArrayList(Property),
alternate: *std.ArrayList(AlternateLink),
) !void {
const rel = el.getAttributeSafe(comptime .wrap("rel")) orelse return;
const raw_href = el.getAttributeSafe(comptime .wrap("href")) orelse return;
const href = URL.resolve(arena, page.base(), raw_href, .{ .encode = true }) catch raw_href;
if (std.ascii.eqlIgnoreCase(rel, "alternate")) {
try alternate.append(arena, .{
.href = href,
.hreflang = el.getAttributeSafe(comptime .wrap("hreflang")),
.type = el.getAttributeSafe(comptime .wrap("type")),
.title = el.getAttributeSafe(comptime .wrap("title")),
});
return;
}
const relevant_rels = [_][]const u8{
"canonical", "icon", "manifest", "shortcut icon",
"apple-touch-icon", "search", "author", "license",
"dns-prefetch", "preconnect",
};
for (relevant_rels) |known| {
if (std.ascii.eqlIgnoreCase(rel, known)) {
try links.append(arena, .{ .key = known, .value = href });
return;
}
}
}
// --- Tests ---
const testing = @import("../testing.zig");
fn testStructuredData(html: []const u8) !StructuredData {
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);
return collectStructuredData(div.asNode(), page.call_arena, page);
}
fn findProperty(props: []const Property, key: []const u8) ?[]const u8 {
for (props) |p| {
if (std.mem.eql(u8, p.key, key)) return p.value;
}
return null;
}
test "structured_data: json-ld" {
const data = try testStructuredData(
\\<script type="application/ld+json">
\\{"@context":"https://schema.org","@type":"Article","headline":"Test"}
\\</script>
);
try testing.expectEqual(1, data.json_ld.len);
try testing.expect(std.mem.indexOf(u8, data.json_ld[0], "Article") != null);
}
test "structured_data: multiple json-ld" {
const data = try testStructuredData(
\\<script type="application/ld+json">{"@type":"Organization"}</script>
\\<script type="application/ld+json">{"@type":"BreadcrumbList"}</script>
\\<script type="text/javascript">var x = 1;</script>
);
try testing.expectEqual(2, data.json_ld.len);
}
test "structured_data: open graph" {
const data = try testStructuredData(
\\<meta property="og:title" content="My Page">
\\<meta property="og:description" content="A description">
\\<meta property="og:image" content="https://example.com/img.jpg">
\\<meta property="og:url" content="https://example.com">
\\<meta property="og:type" content="article">
\\<meta property="article:published_time" content="2026-03-10">
);
try testing.expectEqual(6, data.open_graph.len);
try testing.expectEqual("My Page", findProperty(data.open_graph, "title").?);
try testing.expectEqual("article", findProperty(data.open_graph, "type").?);
try testing.expectEqual("2026-03-10", findProperty(data.open_graph, "article:published_time").?);
}
test "structured_data: open graph duplicate keys" {
const data = try testStructuredData(
\\<meta property="og:title" content="My Page">
\\<meta property="og:image" content="https://example.com/img1.jpg">
\\<meta property="og:image" content="https://example.com/img2.jpg">
\\<meta property="og:image" content="https://example.com/img3.jpg">
);
// Duplicate keys are preserved as separate Property entries.
try testing.expectEqual(4, data.open_graph.len);
// Verify serialization groups duplicates into arrays.
const json = try std.json.Stringify.valueAlloc(testing.allocator, data, .{});
defer testing.allocator.free(json);
const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
defer parsed.deinit();
const og = parsed.value.object.get("openGraph").?.object;
// "title" appears once → string.
switch (og.get("title").?) {
.string => {},
else => return error.TestUnexpectedResult,
}
// "image" appears 3 times → array.
switch (og.get("image").?) {
.array => |arr| try testing.expectEqual(3, arr.items.len),
else => return error.TestUnexpectedResult,
}
}
test "structured_data: twitter card" {
const data = try testStructuredData(
\\<meta name="twitter:card" content="summary_large_image">
\\<meta name="twitter:site" content="@example">
\\<meta name="twitter:title" content="My Page">
);
try testing.expectEqual(3, data.twitter_card.len);
try testing.expectEqual("summary_large_image", findProperty(data.twitter_card, "card").?);
try testing.expectEqual("@example", findProperty(data.twitter_card, "site").?);
}
test "structured_data: meta tags" {
const data = try testStructuredData(
\\<title>Page Title</title>
\\<meta name="description" content="A test page">
\\<meta name="author" content="Test Author">
\\<meta name="keywords" content="test, example">
\\<meta name="robots" content="index, follow">
);
try testing.expectEqual("Page Title", findProperty(data.meta, "title").?);
try testing.expectEqual("A test page", findProperty(data.meta, "description").?);
try testing.expectEqual("Test Author", findProperty(data.meta, "author").?);
try testing.expectEqual("test, example", findProperty(data.meta, "keywords").?);
try testing.expectEqual("index, follow", findProperty(data.meta, "robots").?);
}
test "structured_data: link elements" {
const data = try testStructuredData(
\\<link rel="canonical" href="https://example.com/page">
\\<link rel="icon" href="/favicon.ico">
\\<link rel="manifest" href="/manifest.json">
\\<link rel="stylesheet" href="/style.css">
);
try testing.expectEqual(3, data.links.len);
try testing.expectEqual("https://example.com/page", findProperty(data.links, "canonical").?);
// stylesheet should be filtered out
try testing.expectEqual(null, findProperty(data.links, "stylesheet"));
}
test "structured_data: alternate links" {
const data = try testStructuredData(
\\<link rel="alternate" href="https://example.com/fr" hreflang="fr" title="French">
\\<link rel="alternate" href="https://example.com/de" hreflang="de">
);
try testing.expectEqual(2, data.alternate.len);
try testing.expectEqual("fr", data.alternate[0].hreflang.?);
try testing.expectEqual("French", data.alternate[0].title.?);
try testing.expectEqual("de", data.alternate[1].hreflang.?);
try testing.expectEqual(null, data.alternate[1].title);
}
test "structured_data: non-metadata elements ignored" {
const data = try testStructuredData(
\\<div>Just text</div>
\\<p>More text</p>
\\<a href="/link">Link</a>
);
try testing.expectEqual(0, data.json_ld.len);
try testing.expectEqual(0, data.open_graph.len);
try testing.expectEqual(0, data.twitter_card.len);
try testing.expectEqual(0, data.meta.len);
try testing.expectEqual(0, data.links.len);
}
test "structured_data: charset and http-equiv" {
const data = try testStructuredData(
\\<meta charset="utf-8">
\\<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
);
try testing.expectEqual("utf-8", findProperty(data.meta, "charset").?);
try testing.expectEqual("text/html; charset=utf-8", findProperty(data.meta, "Content-Type").?);
}
test "structured_data: mixed content" {
const data = try testStructuredData(
\\<title>My Site</title>
\\<meta property="og:title" content="OG Title">
\\<meta name="twitter:card" content="summary">
\\<meta name="description" content="A page">
\\<link rel="canonical" href="https://example.com">
\\<script type="application/ld+json">{"@type":"WebSite"}</script>
);
try testing.expectEqual(1, data.json_ld.len);
try testing.expectEqual(1, data.open_graph.len);
try testing.expectEqual(1, data.twitter_card.len);
try testing.expectEqual("My Site", findProperty(data.meta, "title").?);
try testing.expectEqual("A page", findProperty(data.meta, "description").?);
try testing.expectEqual(1, data.links.len);
}

View File

@@ -98,6 +98,64 @@
}
</script>
<script id=mime_parsing>
// MIME types are lowercased
{
const blob = new Blob([], { type: "TEXT/HTML" });
testing.expectEqual("text/html", blob.type);
}
{
const blob = new Blob([], { type: "Application/JSON" });
testing.expectEqual("application/json", blob.type);
}
// MIME with parameters - lowercased
{
const blob = new Blob([], { type: "text/html; charset=UTF-8" });
testing.expectEqual("text/html; charset=utf-8", blob.type);
}
// Any ASCII string is accepted and lowercased (no MIME structure validation)
{
const blob = new Blob([], { type: "invalid" });
testing.expectEqual("invalid", blob.type);
}
{
const blob = new Blob([], { type: "/" });
testing.expectEqual("/", blob.type);
}
// Non-ASCII characters cause empty string (chars outside U+0020-U+007E)
{
const blob = new Blob([], { type: "ý/x" });
testing.expectEqual("", blob.type);
}
{
const blob = new Blob([], { type: "text/plàin" });
testing.expectEqual("", blob.type);
}
// Control characters cause empty string
{
const blob = new Blob([], { type: "text/html\x00" });
testing.expectEqual("", blob.type);
}
// Empty type stays empty
{
const blob = new Blob([]);
testing.expectEqual("", blob.type);
}
{
const blob = new Blob([], { type: "" });
testing.expectEqual("", blob.type);
}
</script>
<script id=slice>
{
const parts = ["la", "symphonie", "des", "éclairs"];

View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="constructor_basic">
{
const face = new FontFace("TestFont", "url(test.woff)");
testing.expectTrue(face instanceof FontFace);
}
</script>
<script id="constructor_name">
{
testing.expectEqual('FontFace', FontFace.name);
}
</script>
<script id="family_property">
{
const face = new FontFace("MyFont", "url(font.woff2)");
testing.expectEqual("MyFont", face.family);
}
</script>
<script id="status_is_loaded">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectEqual("loaded", face.status);
}
</script>
<script id="loaded_is_promise">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectTrue(face.loaded instanceof Promise);
}
</script>
<script id="load_returns_promise">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectTrue(face.load() instanceof Promise);
}
</script>
<script id="default_descriptors">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectEqual("normal", face.style);
testing.expectEqual("normal", face.weight);
testing.expectEqual("normal", face.stretch);
testing.expectEqual("normal", face.variant);
testing.expectEqual("normal", face.featureSettings);
testing.expectEqual("auto", face.display);
}
</script>
<script id="document_fonts_add">
{
const face = new FontFace("AddedFont", "url(added.woff)");
const result = document.fonts.add(face);
testing.expectTrue(result === document.fonts);
}
</script>

View File

@@ -256,3 +256,166 @@
testing.expectTrue(!html.includes('opacity:0'));
}
</script>
<script id="CSSStyleDeclaration_non_ascii_custom_property">
{
// Regression test: accessing element.style must not crash when the inline
// style attribute contains CSS custom properties with non-ASCII (UTF-8
// multibyte) names, such as French accented characters.
// The CSS Tokenizer's consumeName() must advance over whole UTF-8 sequences
// rather than byte-by-byte to avoid landing on a continuation byte.
const div = document.createElement('div');
div.setAttribute('style',
'--color-store-bulles-\u00e9t\u00e9-fg: #6a818f;' +
'--color-store-soir\u00e9es-odl-fg: #56b3b3;' +
'color: red;'
);
// Must not crash, and ASCII properties that follow non-ASCII ones must be readable.
testing.expectEqual('red', div.style.getPropertyValue('color'));
}
</script>
<script id="CSSStyleDeclaration_normalize_zero_to_0px">
{
// Per CSSOM spec, unitless zero in length properties should serialize as "0px"
const div = document.createElement('div');
div.style.width = '0';
testing.expectEqual('0px', div.style.width);
div.style.margin = '0';
testing.expectEqual('0px', div.style.margin);
div.style.padding = '0';
testing.expectEqual('0px', div.style.padding);
div.style.top = '0';
testing.expectEqual('0px', div.style.top);
// Scroll properties
div.style.scrollMarginTop = '0';
testing.expectEqual('0px', div.style.scrollMarginTop);
div.style.scrollPaddingBottom = '0';
testing.expectEqual('0px', div.style.scrollPaddingBottom);
// Multi-column
div.style.columnWidth = '0';
testing.expectEqual('0px', div.style.columnWidth);
div.style.columnRuleWidth = '0';
testing.expectEqual('0px', div.style.columnRuleWidth);
// Outline shorthand
div.style.outline = '0';
testing.expectEqual('0px', div.style.outline);
// Shapes
div.style.shapeMargin = '0';
testing.expectEqual('0px', div.style.shapeMargin);
// Non-length properties should not be affected
div.style.opacity = '0';
testing.expectEqual('0', div.style.opacity);
div.style.zIndex = '0';
testing.expectEqual('0', div.style.zIndex);
}
</script>
<script id="CSSStyleDeclaration_normalize_first_baseline">
{
// "first baseline" should serialize canonically as "baseline"
const div = document.createElement('div');
div.style.alignItems = 'first baseline';
testing.expectEqual('baseline', div.style.alignItems);
div.style.alignContent = 'first baseline';
testing.expectEqual('baseline', div.style.alignContent);
div.style.alignSelf = 'first baseline';
testing.expectEqual('baseline', div.style.alignSelf);
div.style.justifySelf = 'first baseline';
testing.expectEqual('baseline', div.style.justifySelf);
// "last baseline" should remain unchanged
div.style.alignItems = 'last baseline';
testing.expectEqual('last baseline', div.style.alignItems);
}
</script>
<script id="CSSStyleDeclaration_normalize_duplicate_values">
{
// For 2-value shorthand properties, "X X" should collapse to "X"
const div = document.createElement('div');
div.style.placeContent = 'center center';
testing.expectEqual('center', div.style.placeContent);
div.style.placeContent = 'start start';
testing.expectEqual('start', div.style.placeContent);
div.style.gap = '10px 10px';
testing.expectEqual('10px', div.style.gap);
// Different values should not collapse
div.style.placeContent = 'center start';
testing.expectEqual('center start', div.style.placeContent);
div.style.gap = '10px 20px';
testing.expectEqual('10px 20px', div.style.gap);
// New shorthands
div.style.overflow = 'hidden hidden';
testing.expectEqual('hidden', div.style.overflow);
div.style.scrollSnapAlign = 'start start';
testing.expectEqual('start', div.style.scrollSnapAlign);
div.style.overscrollBehavior = 'auto auto';
testing.expectEqual('auto', div.style.overscrollBehavior);
}
</script>
<script id="CSSStyleDeclaration_normalize_anchor_size">
{
// anchor-size() should serialize with dashed ident (anchor name) before size keyword
const div = document.createElement('div');
// Already canonical order - should stay the same
div.style.width = 'anchor-size(--foo width)';
testing.expectEqual('anchor-size(--foo width)', div.style.width);
// Non-canonical order - should be reordered
div.style.width = 'anchor-size(width --foo)';
testing.expectEqual('anchor-size(--foo width)', div.style.width);
// With fallback value
div.style.width = 'anchor-size(height --bar, 100px)';
testing.expectEqual('anchor-size(--bar height, 100px)', div.style.width);
// Different size keywords
div.style.width = 'anchor-size(block --baz)';
testing.expectEqual('anchor-size(--baz block)', div.style.width);
div.style.width = 'anchor-size(inline --qux)';
testing.expectEqual('anchor-size(--qux inline)', div.style.width);
div.style.width = 'anchor-size(self-block --test)';
testing.expectEqual('anchor-size(--test self-block)', div.style.width);
div.style.width = 'anchor-size(self-inline --test)';
testing.expectEqual('anchor-size(--test self-inline)', div.style.width);
// Without anchor name (implicit default anchor)
div.style.width = 'anchor-size(width)';
testing.expectEqual('anchor-size(width)', div.style.width);
// Nested anchor-size in fallback
div.style.width = 'anchor-size(width --foo, anchor-size(height --bar))';
testing.expectEqual('anchor-size(--foo width, anchor-size(--bar height))', div.style.width);
}
</script>

View File

@@ -53,3 +53,78 @@
testing.expectEqual('NO-CONSTRUCTOR-ELEMENT', el.tagName);
}
</script>
<div id=clone_container></div>
<script id=clone>
{
let calls = 0;
class MyCloneElementA extends HTMLElement {
constructor() {
super();
calls += 1;
$('#clone_container').appendChild(this);
}
}
customElements.define('my-clone_element_a', MyCloneElementA);
const original = document.createElement('my-clone_element_a');
$('#clone_container').cloneNode(true);
testing.expectEqual(2, calls);
}
</script>
<div id=fragment_clone_container></div>
<script id=clone_fragment>
{
let calls = 0;
class MyFragmentCloneElement extends HTMLElement {
constructor() {
super();
calls += 1;
$('#fragment_clone_container').appendChild(this);
}
}
customElements.define('my-fragment-clone-element', MyFragmentCloneElement);
// Create a DocumentFragment with a custom element
const fragment = document.createDocumentFragment();
const customEl = document.createElement('my-fragment-clone-element');
fragment.appendChild(customEl);
// Clone the fragment - this should trigger the crash
// because the constructor will attach the element during cloning
const clonedFragment = fragment.cloneNode(true);
testing.expectEqual(2, calls);
}
</script>
<div id=range_clone_container></div>
<script id=clone_range>
{
let calls = 0;
class MyRangeCloneElement extends HTMLElement {
constructor() {
super();
calls += 1;
$('#range_clone_container').appendChild(this);
}
}
customElements.define('my-range-clone-element', MyRangeCloneElement);
// Create a container with a custom element
const container = document.createElement('div');
const customEl = document.createElement('my-range-clone-element');
container.appendChild(customEl);
// Create a range that includes the custom element
const range = document.createRange();
range.selectNodeContents(container);
// Clone the range contents - this should trigger the crash
// because the constructor will attach the element during cloning
const clonedContents = range.cloneContents();
testing.expectEqual(2, calls);
}
</script>

View File

@@ -111,3 +111,15 @@
const containerDataTest = document.querySelector('#container [data-test]');
testing.expectEqual('First', containerDataTest.innerText);
</script>
<link rel="preload" as="image" imagesrcset="url1.png 1x, url2.png 2x" id="preload-link">
<script id="commaInAttrValue">
// Commas inside quoted attribute values must not be treated as selector separators
const el = document.querySelector('link[rel="preload"][as="image"][imagesrcset="url1.png 1x, url2.png 2x"]');
testing.expectEqual('preload-link', el.id);
// Also test with single quotes inside selector
const el2 = document.querySelector("link[imagesrcset='url1.png 1x, url2.png 2x']");
testing.expectEqual('preload-link', el2.id);
</script>

View File

@@ -4,9 +4,17 @@
<script id=basic>
{
const parser = new DOMParser();
testing.expectEqual('object', typeof parser);
testing.expectEqual('function', typeof parser.parseFromString);
{
const parser = new DOMParser();
testing.expectEqual('object', typeof parser);
testing.expectEqual('function', typeof parser.parseFromString);
}
{
// Empty XML is a parse error (no root element)
const parser = new DOMParser();
testing.expectError('Error', () => parser.parseFromString('', 'text/xml'));
}
}
</script>
@@ -389,3 +397,25 @@
}
}
</script>
<script id=getElementsByTagName-xml>
{
const parser = new DOMParser();
const doc = parser.parseFromString('<layout><row><col>A</col><col>B</col></row></layout>', 'text/xml');
// Test getElementsByTagName on document
const rows = doc.getElementsByTagName('row');
testing.expectEqual(1, rows.length);
// Test getElementsByTagName on element
const row = rows[0];
const cols = row.getElementsByTagName('col');
testing.expectEqual(2, cols.length);
testing.expectEqual('A', cols[0].textContent);
testing.expectEqual('B', cols[1].textContent);
// Test getElementsByTagName('*') on element
const allElements = row.getElementsByTagName('*');
testing.expectEqual(2, allElements.length);
}
</script>

View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<!-- Details elements -->
<details id="details1">
<summary>Summary</summary>
Content
</details>
<details id="details2" open>
<summary>Open Summary</summary>
Content
</details>
<script id="instanceof">
{
const details = document.createElement('details')
testing.expectTrue(details instanceof HTMLDetailsElement)
}
</script>
<script id="open_initial">
testing.expectEqual(false, $('#details1').open)
testing.expectEqual(true, $('#details2').open)
</script>
<script id="open_set">
{
$('#details1').open = true
testing.expectEqual(true, $('#details1').open)
$('#details2').open = false
testing.expectEqual(false, $('#details2').open)
}
</script>
<script id="open_reflects_attribute">
{
const details = document.createElement('details')
testing.expectEqual(null, details.getAttribute('open'))
details.open = true
testing.expectEqual('', details.getAttribute('open'))
details.open = false
testing.expectEqual(null, details.getAttribute('open'))
}
</script>
<script id="name_initial">
{
const details = document.createElement('details')
testing.expectEqual('', details.name)
}
</script>
<script id="name_set">
{
const details = document.createElement('details')
details.name = 'group1'
testing.expectEqual('group1', details.name)
testing.expectEqual('group1', details.getAttribute('name'))
}
</script>

View File

@@ -23,6 +23,22 @@
}
</script>
<script id="action">
{
const form = document.createElement('form')
testing.expectEqual(testing.BASE_URL + 'element/html/form.html', form.action)
form.action = 'hello';
testing.expectEqual(testing.BASE_URL + 'element/html/hello', form.action)
form.action = '/hello';
testing.expectEqual(testing.ORIGIN + 'hello', form.action)
form.action = 'https://lightpanda.io/hello';
testing.expectEqual('https://lightpanda.io/hello', form.action)
}
</script>
<!-- Test fixtures for form.method -->
<form id="form_get" method="get"></form>
<form id="form_post" method="post"></form>

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<head></head>
<script src="../../../testing.js"></script>
<script id=textContent_inline>
window.inline_executed = false;
const s1 = document.createElement('script');
s1.textContent = 'window.inline_executed = true;';
document.head.appendChild(s1);
testing.expectTrue(window.inline_executed);
</script>
<script id=text_property_inline>
window.text_executed = false;
const s2 = document.createElement('script');
s2.text = 'window.text_executed = true;';
document.head.appendChild(s2);
testing.expectTrue(window.text_executed);
</script>
<script id=innerHTML_inline>
window.innerHTML_executed = false;
const s3 = document.createElement('script');
s3.innerHTML = 'window.innerHTML_executed = true;';
document.head.appendChild(s3);
testing.expectTrue(window.innerHTML_executed);
</script>
<script id=no_double_execute_inline>
window.inline_counter = 0;
const s4 = document.createElement('script');
s4.textContent = 'window.inline_counter++;';
document.head.appendChild(s4);
document.head.appendChild(s4);
testing.expectEqual(1, window.inline_counter);
</script>
<script id=empty_script_no_execute>
window.empty_ran = false;
const s5 = document.createElement('script');
document.head.appendChild(s5);
testing.expectFalse(window.empty_ran);
</script>
<script id=module_inline>
window.module_executed = false;
const s6 = document.createElement('script');
s6.type = 'module';
s6.textContent = 'window.module_executed = true;';
document.head.appendChild(s6);
testing.eventually(() => {
testing.expectTrue(window.module_executed);
});
</script>

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<video id="video1">
<track id="track1" kind="subtitles">
<track id="track2" kind="captions">
<track id="track3" kind="invalid-kind">
</video>
<script id="instanceof">
{
const track = document.createElement("track");
testing.expectEqual(true, track instanceof HTMLTrackElement);
testing.expectEqual("[object HTMLTrackElement]", track.toString());
}
</script>
<script id="kind_default">
{
const track = document.createElement("track");
testing.expectEqual("subtitles", track.kind);
}
</script>
<script id="kind_valid_values">
{
const track = document.createElement("track");
track.kind = "captions";
testing.expectEqual("captions", track.kind);
track.kind = "descriptions";
testing.expectEqual("descriptions", track.kind);
track.kind = "chapters";
testing.expectEqual("chapters", track.kind);
track.kind = "metadata";
testing.expectEqual("metadata", track.kind);
}
</script>
<script id="kind_invalid">
{
const track = document.createElement("track");
track.kind = null;
testing.expectEqual("metadata", track.kind);
track.kind = "Subtitles";
testing.expectEqual("subtitles", track.kind);
track.kind = "";
testing.expectEqual("metadata", track.kind);
}
</script>
<script id="constants">
{
const track = document.createElement("track");
testing.expectEqual(0, track.NONE);
testing.expectEqual(1, track.LOADING);
testing.expectEqual(2, track.LOADED);
testing.expectEqual(3, track.ERROR);
}
</script>
<script id="constants_static">
{
testing.expectEqual(0, HTMLTrackElement.NONE);
testing.expectEqual(1, HTMLTrackElement.LOADING);
testing.expectEqual(2, HTMLTrackElement.LOADED);
testing.expectEqual(3, HTMLTrackElement.ERROR);
}
</script>

View File

@@ -81,6 +81,17 @@
}
</script>
<script id="is_empty">
{
// Empty :is() and :where() are valid per spec and match nothing
const isEmptyResult = document.querySelectorAll(':is()');
testing.expectEqual(0, isEmptyResult.length);
const whereEmptyResult = document.querySelectorAll(':where()');
testing.expectEqual(0, whereEmptyResult.length);
}
</script>
<div id=escaped class=":popover-open"></div>
<script id="escaped">
{

View File

@@ -12,8 +12,6 @@
// Empty functional pseudo-classes should error
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':has()'));
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':not()'));
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':is()'));
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':where()'));
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':lang()'));
}
</script>

View File

@@ -3,52 +3,73 @@
<script>
function frame1Onload() {
window.f1_onload = true;
window.f1_onload = 'f1_onload_loaded';
}
</script>
<iframe id=f1 onload="frame1Onload" src="support/sub 1.html"></iframe>
<iframe id=f0></iframe>
<iframe id=f1 onload="frame1Onload()" src="support/sub 1.html"></iframe>
<iframe id=f2 src="support/sub2.html"></iframe>
<script id="basic">
testing.eventually(() => {
testing.expectEqual(undefined, window[10]);
<script id=empty>
{
const blank = document.createElement('iframe');
testing.expectEqual(null, blank.contentDocument);
document.documentElement.appendChild(blank);
testing.expectEqual('<html><head></head><body></body></html>', blank.contentDocument.documentElement.outerHTML);
testing.expectEqual(window, window[0].top);
testing.expectEqual(window, window[0].parent);
testing.expectEqual(false, window === window[0]);
const f0 = $('#f0')
testing.expectEqual('<html><head></head><body></body></html>', f0.contentDocument.documentElement.outerHTML);
}
</script>
<script id="basic">
// reload it
$('#f2').src = 'support/sub2.html';
testing.expectEqual(true, true);
testing.eventually(() => {
testing.expectEqual(undefined, window[20]);
testing.expectEqual(window, window[1].top);
testing.expectEqual(window, window[1].parent);
testing.expectEqual(false, window === window[1]);
testing.expectEqual(false, window[0] === window[1]);
testing.expectEqual(window, window[2].top);
testing.expectEqual(window, window[2].parent);
testing.expectEqual(false, window === window[2]);
testing.expectEqual(false, window[1] === window[2]);
testing.expectEqual(0, $('#f1').childNodes.length);
testing.expectEqual(testing.BASE_URL + 'frames/support/sub%201.html', $('#f1').src);
testing.expectEqual(window[0], $('#f1').contentWindow);
testing.expectEqual(window[1], $('#f2').contentWindow);
testing.expectEqual(window[1], $('#f1').contentWindow);
testing.expectEqual(window[2], $('#f2').contentWindow);
testing.expectEqual(window[0].document, $('#f1').contentDocument);
testing.expectEqual(window[1].document, $('#f2').contentDocument);
testing.expectEqual(window[1].document, $('#f1').contentDocument);
testing.expectEqual(window[2].document, $('#f2').contentDocument);
// sibling frames share the same top
testing.expectEqual(window[0].top, window[1].top);
testing.expectEqual(window[1].top, window[2].top);
// child frames have no sub-frames
testing.expectEqual(0, window[0].length);
testing.expectEqual(0, window[1].length);
testing.expectEqual(0, window[2].length);
// self and window are self-referential on child frames
testing.expectEqual(window[0], window[0].self);
testing.expectEqual(window[0], window[0].window);
testing.expectEqual(window[1], window[1].self);
testing.expectEqual(window[1], window[1].window);
testing.expectEqual(window[2], window[2].self);
// child frame's top.parent is itself (root has no parent)
testing.expectEqual(window, window[0].top.parent);
// testing.expectEqual(true, window.sub1_loaded);
// testing.expectEqual(true, window.sub2_loaded);
// Cross-frame property access
testing.expectEqual(true, window.sub1_loaded);
testing.expectEqual(true, window.sub2_loaded);
testing.expectEqual(1, window.sub1_count);
// depends on how far the initial load got before it was cancelled.
testing.expectEqual(true, window.sub2_count == 1 || window.sub2_count == 2);
});
</script>
@@ -56,6 +77,7 @@
{
let f3_load_event = false;
let f3 = document.createElement('iframe');
f3.id = 'f3';
f3.addEventListener('load', () => {
f3_load_event = true;
});
@@ -63,14 +85,62 @@
document.documentElement.appendChild(f3);
testing.eventually(() => {
testing.expectEqual(true, window.f1_onload);
testing.expectEqual('f1_onload_loaded', window.f1_onload);
testing.expectEqual(true, f3_load_event);
});
}
</script>
<script id=count>
testing.eventually(() => {
testing.expectEqual(3, window.length);
<script id=about_blank>
{
let f4 = document.createElement('iframe');
f4.id = 'f4';
f4.src = "about:blank";
document.documentElement.appendChild(f4);
testing.eventually(() => {
testing.expectEqual("<html><head></head><body></body></html>", f4.contentDocument.documentElement.outerHTML);
});
}
</script>
<script id=about_blank_renavigate>
{
let f5 = document.createElement('iframe');
f5.id = 'f5';
f5.src = "support/sub 1.html";
document.documentElement.appendChild(f5);
f5.src = "about:blank";
testing.eventually(() => {
testing.expectEqual("<html><head></head><body></body></html>", f5.contentDocument.documentElement.outerHTML);
});
}
</script>
<script id=link_click>
testing.async(async (restore) => {
await new Promise((resolve) => {
let count = 0;
let f6 = document.createElement('iframe');
f6.id = 'f6';
f6.addEventListener('load', () => {
if (++count == 2) {
resolve();
return;
}
f6.contentDocument.querySelector('#link').click();
});
f6.src = "support/with_link.html";
document.documentElement.appendChild(f6);
});
restore();
testing.expectEqual("<html><head></head><body>It was clicked!\n</body></html>", f6.contentDocument.documentElement.outerHTML);
});
</script>
<script id=count>
testing.eventually(() => {
testing.expectEqual(8, window.length);
});
</script>

View File

@@ -0,0 +1,2 @@
<!DOCTYPE html>
It was clicked!

View File

@@ -3,4 +3,5 @@
<script>
// should not have access to the parent's JS context
window.top.sub1_loaded = window.testing == undefined;
window.top.sub1_count = (window.top.sub1_count || 0) + 1;
</script>

View File

@@ -4,4 +4,5 @@
<script>
// should not have access to the parent's JS context
window.top.sub2_loaded = window.testing == undefined;
window.top.sub2_count = (window.top.sub2_count || 0) + 1;
</script>

View File

@@ -0,0 +1,2 @@
<!DOCTYPE html>
<a href="support/after_link.html" id=link>a link</a>

View File

@@ -6,6 +6,7 @@
</html>
<script src="../testing.js"></script>
<applet></applet>
<script id=document>
testing.expectEqual('HTMLDocument', document.__proto__.constructor.name);
@@ -23,7 +24,7 @@
testing.expectEqual(2, document.scripts.length);
testing.expectEqual(0, document.forms.length);
testing.expectEqual(1, document.links.length);
testing.expectEqual(0, document.applets.length);
testing.expectEqual(0, document.applets.length); // deprecated, always returns 0
testing.expectEqual(0, document.anchors.length);
testing.expectEqual(7, document.all.length);
testing.expectEqual('document', document.currentScript.id);

View File

@@ -137,3 +137,79 @@
testing.expectEqual('PROPFIND', req.method);
}
</script>
<script id=body_methods>
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: 'Hello, World!',
headers: { 'Content-Type': 'text/plain' }
});
const text = await req.text();
testing.expectEqual('Hello, World!', text);
});
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: '{"name": "test"}',
headers: { 'Content-Type': 'application/json' }
});
const json = await req.json();
testing.expectEqual('test', json.name);
});
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: 'binary data',
headers: { 'Content-Type': 'application/octet-stream' }
});
const buffer = await req.arrayBuffer();
testing.expectEqual(true, buffer instanceof ArrayBuffer);
testing.expectEqual(11, buffer.byteLength);
});
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: 'blob content',
headers: { 'Content-Type': 'text/plain' }
});
const blob = await req.blob();
testing.expectEqual(true, blob instanceof Blob);
testing.expectEqual(12, blob.size);
testing.expectEqual('text/plain', blob.type);
});
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: 'bytes'
});
const bytes = await req.bytes();
testing.expectEqual(true, bytes instanceof Uint8Array);
testing.expectEqual(5, bytes.length);
});
</script>
<script id=clone>
{
const req1 = new Request('https://example.com/api', {
method: 'POST',
body: 'test body',
headers: { 'X-Custom': 'value' }
});
const req2 = req1.clone();
testing.expectEqual(req1.url, req2.url);
testing.expectEqual(req1.method, req2.method);
testing.expectEqual('value', req2.headers.get('X-Custom'));
}
</script>

View File

@@ -2,51 +2,113 @@
<script src="../testing.js"></script>
<script id=response>
// let response = new Response("Hello, World!");
// testing.expectEqual(200, response.status);
// testing.expectEqual("", response.statusText);
// testing.expectEqual(true, response.ok);
// testing.expectEqual("", response.url);
// testing.expectEqual(false, response.redirected);
{
let response = new Response("Hello, World!");
testing.expectEqual(200, response.status);
testing.expectEqual("", response.statusText);
testing.expectEqual(true, response.ok);
testing.expectEqual("", response.url);
testing.expectEqual(false, response.redirected);
}
let response2 = new Response("Error occurred", {
status: 404,
statusText: "Not Found",
headers: {
"Content-Type": "text/plain",
"X-Custom": "test-value",
"Cache-Control": "no-cache"
}
});
testing.expectEqual(true, true);
// testing.expectEqual(404, response2.status);
// testing.expectEqual("Not Found", response2.statusText);
// testing.expectEqual(false, response2.ok);
// testing.expectEqual("text/plain", response2.headers);
// testing.expectEqual("test-value", response2.headers.get("X-Custom"));
testing.expectEqual("no-cache", response2.headers.get("cache-control"));
// let response3 = new Response("Created", { status: 201, statusText: "Created" });
// testing.expectEqual("basic", response3.type);
// testing.expectEqual(201, response3.status);
// testing.expectEqual("Created", response3.statusText);
// testing.expectEqual(true, response3.ok);
// let nullResponse = new Response(null);
// testing.expectEqual(200, nullResponse.status);
// testing.expectEqual("", nullResponse.statusText);
// let emptyResponse = new Response("");
// testing.expectEqual(200, emptyResponse.status);
</script>
<!-- <script id=json>
testing.async(async () => {
const json = await new Promise((resolve) => {
let response = new Response('[]');
response.json().then(resolve)
{
let response2 = new Response("Error occurred", {
status: 404,
statusText: "Not Found",
headers: {
"Content-Type": "text/plain",
"X-Custom": "test-value",
"Cache-Control": "no-cache"
}
});
testing.expectEqual([], json);
testing.expectEqual(404, response2.status);
testing.expectEqual("Not Found", response2.statusText);
testing.expectEqual(false, response2.ok);
testing.expectEqual("test-value", response2.headers.get("X-Custom"));
testing.expectEqual("no-cache", response2.headers.get("cache-control"));
}
{
let response3 = new Response("Created", { status: 201, statusText: "Created" });
testing.expectEqual("basic", response3.type);
testing.expectEqual(201, response3.status);
testing.expectEqual("Created", response3.statusText);
testing.expectEqual(true, response3.ok);
}
{
let nullResponse = new Response(null);
testing.expectEqual(200, nullResponse.status);
testing.expectEqual("", nullResponse.statusText);
}
{
let emptyResponse = new Response("");
testing.expectEqual(200, emptyResponse.status);
}
</script>
<script id=body_methods>
testing.async(async () => {
const response = new Response('Hello, World!');
const text = await response.text();
testing.expectEqual('Hello, World!', text);
});
testing.async(async () => {
const response = new Response('{"name": "test"}');
const json = await response.json();
testing.expectEqual('test', json.name);
});
testing.async(async () => {
const response = new Response('binary data');
const buffer = await response.arrayBuffer();
testing.expectEqual(true, buffer instanceof ArrayBuffer);
testing.expectEqual(11, buffer.byteLength);
});
testing.async(async () => {
const response = new Response('blob content', {
headers: { 'Content-Type': 'text/plain' }
});
const blob = await response.blob();
testing.expectEqual(true, blob instanceof Blob);
testing.expectEqual(12, blob.size);
testing.expectEqual('text/plain', blob.type);
});
testing.async(async () => {
const response = new Response('bytes');
const bytes = await response.bytes();
testing.expectEqual(true, bytes instanceof Uint8Array);
testing.expectEqual(5, bytes.length);
});
</script>
<script id=clone>
{
const response1 = new Response('test body', {
status: 201,
statusText: 'Created',
headers: { 'X-Custom': 'value' }
});
const response2 = response1.clone();
testing.expectEqual(response1.status, response2.status);
testing.expectEqual(response1.statusText, response2.statusText);
testing.expectEqual('value', response2.headers.get('X-Custom'));
}
testing.async(async () => {
const response1 = new Response('cloned body');
const response2 = response1.clone();
const text1 = await response1.text();
const text2 = await response2.text();
testing.expectEqual('cloned body', text1);
testing.expectEqual('cloned body', text2);
});
</script>
-->

View File

@@ -1022,3 +1022,50 @@
testing.expectEqual('Stnd', div.textContent);
}
</script>
<script id=getBoundingClientRect_collapsed>
{
const range = new Range();
const rect = range.getBoundingClientRect();
testing.expectTrue(rect instanceof DOMRect);
testing.expectEqual(0, rect.x);
testing.expectEqual(0, rect.y);
testing.expectEqual(0, rect.width);
testing.expectEqual(0, rect.height);
}
</script>
<script id=getBoundingClientRect_element>
{
const range = new Range();
const p = document.getElementById('p1');
range.selectNodeContents(p);
const rect = range.getBoundingClientRect();
testing.expectTrue(rect instanceof DOMRect);
// Non-collapsed range delegates to the container element
const elemRect = p.getBoundingClientRect();
testing.expectEqual(elemRect.x, rect.x);
testing.expectEqual(elemRect.y, rect.y);
testing.expectEqual(elemRect.width, rect.width);
testing.expectEqual(elemRect.height, rect.height);
}
</script>
<script id=getClientRects_collapsed>
{
const range = new Range();
const rects = range.getClientRects();
testing.expectEqual(0, rects.length);
}
</script>
<script id=getClientRects_element>
{
const range = new Range();
const p = document.getElementById('p1');
range.selectNodeContents(p);
const rects = range.getClientRects();
const elemRects = p.getClientRects();
testing.expectEqual(elemRects.length, rects.length);
}
</script>

View File

@@ -0,0 +1,315 @@
<!DOCTYPE html>
<script src="testing.js"></script>
<script id=insertData_adjusts_range_offsets>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// range covers "cde"
// Insert "XX" at offset 1 (before range start)
text.insertData(1, 'XX');
// "aXXbcdef" — range should shift right by 2
testing.expectEqual(4, range.startOffset);
testing.expectEqual(7, range.endOffset);
testing.expectEqual(text, range.startContainer);
}
</script>
<script id=insertData_at_range_start>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Insert at exactly the start offset — should not shift start
text.insertData(2, 'YY');
// "abYYcdef" — start stays at 2, end shifts by 2
testing.expectEqual(2, range.startOffset);
testing.expectEqual(7, range.endOffset);
}
</script>
<script id=insertData_inside_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Insert inside the range
text.insertData(3, 'Z');
// "abcZdef" — start unchanged, end shifts by 1
testing.expectEqual(2, range.startOffset);
testing.expectEqual(6, range.endOffset);
}
</script>
<script id=insertData_after_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Insert after range end — no change
text.insertData(5, 'ZZ');
testing.expectEqual(2, range.startOffset);
testing.expectEqual(5, range.endOffset);
}
</script>
<script id=deleteData_before_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 3);
range.setEnd(text, 5);
// range covers "de"
// Delete "ab" (offset 0, count 2) — before range
text.deleteData(0, 2);
// "cdef" — range shifts left by 2
testing.expectEqual(1, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=deleteData_overlapping_range_start>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Delete from offset 1, count 2 — overlaps range start
text.deleteData(1, 2);
// "adef" — start clamped to offset(1), end adjusted
testing.expectEqual(1, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=deleteData_inside_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 1);
range.setEnd(text, 5);
// Delete inside range: offset 2, count 2
text.deleteData(2, 2);
// "abef" — start unchanged, end shifts by -2
testing.expectEqual(1, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=replaceData_adjusts_range>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Replace "cd" (offset 2, count 2) with "XXXX" (4 chars)
text.replaceData(2, 2, 'XXXX');
// "abXXXXef" — start clamped to 2, end adjusted by (4-2)=+2
testing.expectEqual(2, range.startOffset);
testing.expectEqual(7, range.endOffset);
}
</script>
<script id=splitText_moves_range_to_new_node>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 4);
range.setEnd(text, 6);
// range covers "ef"
const newText = text.splitText(3);
// text = "abc", newText = "def"
// Range was at (text, 4)-(text, 6), with offset > 3:
// start moves to (newText, 4-3=1), end moves to (newText, 6-3=3)
testing.expectEqual(newText, range.startContainer);
testing.expectEqual(1, range.startOffset);
testing.expectEqual(newText, range.endContainer);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=splitText_range_at_split_point>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 0);
range.setEnd(text, 3);
// range covers "abc"
const newText = text.splitText(3);
// text = "abc", newText = "def"
// Range end is at exactly the split offset — should stay on original node
testing.expectEqual(text, range.startContainer);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(text, range.endContainer);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=appendChild_does_not_affect_range>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
div.appendChild(p1);
div.appendChild(p2);
const range = document.createRange();
range.setStart(div, 0);
range.setEnd(div, 2);
// Appending should not affect range offsets (spec: no update for append)
const p3 = document.createElement('p');
div.appendChild(p3);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(2, range.endOffset);
}
</script>
<script id=insertBefore_shifts_range_offsets>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
div.appendChild(p1);
div.appendChild(p2);
const range = document.createRange();
range.setStart(div, 1);
range.setEnd(div, 2);
// Insert before p1 (index 0) — range offsets > 0 should increment
const span = document.createElement('span');
div.insertBefore(span, p1);
testing.expectEqual(2, range.startOffset);
testing.expectEqual(3, range.endOffset);
}
</script>
<script id=removeChild_shifts_range_offsets>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
const p3 = document.createElement('p');
div.appendChild(p1);
div.appendChild(p2);
div.appendChild(p3);
const range = document.createRange();
range.setStart(div, 1);
range.setEnd(div, 3);
// Remove p1 (index 0) — offsets > 0 should decrement
div.removeChild(p1);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(2, range.endOffset);
}
</script>
<script id=removeChild_moves_range_from_descendant>
{
const div = document.createElement('div');
const p = document.createElement('p');
const text = document.createTextNode('hello');
p.appendChild(text);
div.appendChild(p);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 4);
// Remove p (which contains text) — range should move to (div, index_of_p)
div.removeChild(p);
testing.expectEqual(div, range.startContainer);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(div, range.endContainer);
testing.expectEqual(0, range.endOffset);
}
</script>
<script id=multiple_ranges_updated>
{
const text = document.createTextNode('abcdefgh');
const div = document.createElement('div');
div.appendChild(text);
const range1 = document.createRange();
range1.setStart(text, 1);
range1.setEnd(text, 3);
const range2 = document.createRange();
range2.setStart(text, 5);
range2.setEnd(text, 7);
// Insert at offset 0 — both ranges should shift
text.insertData(0, 'XX');
testing.expectEqual(3, range1.startOffset);
testing.expectEqual(5, range1.endOffset);
testing.expectEqual(7, range2.startOffset);
testing.expectEqual(9, range2.endOffset);
}
</script>
<script id=data_setter_updates_ranges>
{
const text = document.createTextNode('abcdef');
const div = document.createElement('div');
div.appendChild(text);
const range = document.createRange();
range.setStart(text, 2);
range.setEnd(text, 5);
// Setting data replaces all content — range collapses to offset 0
text.data = 'new content';
testing.expectEqual(text, range.startContainer);
testing.expectEqual(0, range.startOffset);
testing.expectEqual(text, range.endContainer);
testing.expectEqual(0, range.endOffset);
}
</script>

View File

@@ -301,3 +301,74 @@
testing.expectEqual(false, data3.done);
})();
</script>
<script id=enqueue_preserves_number>
(async function() {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(42);
controller.enqueue(0);
controller.enqueue(3.14);
controller.close();
}
});
const reader = stream.getReader();
const r1 = await reader.read();
testing.expectEqual(false, r1.done);
testing.expectEqual('number', typeof r1.value);
testing.expectEqual(42, r1.value);
const r2 = await reader.read();
testing.expectEqual('number', typeof r2.value);
testing.expectEqual(0, r2.value);
const r3 = await reader.read();
testing.expectEqual('number', typeof r3.value);
testing.expectEqual(3.14, r3.value);
const r4 = await reader.read();
testing.expectEqual(true, r4.done);
})();
</script>
<script id=enqueue_preserves_bool>
(async function() {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(true);
controller.enqueue(false);
controller.close();
}
});
const reader = stream.getReader();
const r1 = await reader.read();
testing.expectEqual('boolean', typeof r1.value);
testing.expectEqual(true, r1.value);
const r2 = await reader.read();
testing.expectEqual('boolean', typeof r2.value);
testing.expectEqual(false, r2.value);
})();
</script>
<script id=enqueue_preserves_object>
(async function() {
const stream = new ReadableStream({
start(controller) {
controller.enqueue({ key: 'value', num: 7 });
controller.close();
}
});
const reader = stream.getReader();
const r1 = await reader.read();
testing.expectEqual('object', typeof r1.value);
testing.expectEqual('value', r1.value.key);
testing.expectEqual(7, r1.value.num);
})();
</script>

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=text_decoder_stream_encoding>
{
const tds = new TextDecoderStream();
testing.expectEqual('utf-8', tds.encoding);
testing.expectEqual('object', typeof tds.readable);
testing.expectEqual('object', typeof tds.writable);
testing.expectEqual(false, tds.fatal);
testing.expectEqual(false, tds.ignoreBOM);
}
</script>
<script id=text_decoder_stream_with_label>
{
const tds = new TextDecoderStream('utf-8');
testing.expectEqual('utf-8', tds.encoding);
}
</script>
<script id=text_decoder_stream_with_opts>
{
const tds = new TextDecoderStream('utf-8', { fatal: true, ignoreBOM: true });
testing.expectEqual(true, tds.fatal);
testing.expectEqual(true, tds.ignoreBOM);
}
</script>
<script id=text_decoder_stream_invalid_label>
{
let errorThrown = false;
try {
new TextDecoderStream('windows-1252');
} catch (e) {
errorThrown = true;
}
testing.expectEqual(true, errorThrown);
}
</script>
<script id=text_decoder_stream_decode>
(async function() {
const tds = new TextDecoderStream();
const writer = tds.writable.getWriter();
const reader = tds.readable.getReader();
// 'hello' in UTF-8 bytes
const bytes = new Uint8Array([104, 101, 108, 108, 111]);
await writer.write(bytes);
await writer.close();
const result = await reader.read();
testing.expectEqual(false, result.done);
testing.expectEqual('hello', result.value);
const result2 = await reader.read();
testing.expectEqual(true, result2.done);
})();
</script>
<script id=text_decoder_stream_empty_chunk>
(async function() {
const tds = new TextDecoderStream();
const writer = tds.writable.getWriter();
const reader = tds.readable.getReader();
// Write an empty chunk followed by real data
await writer.write(new Uint8Array([]));
await writer.write(new Uint8Array([104, 105]));
await writer.close();
// Empty chunk should be filtered out; first read gets "hi"
const result = await reader.read();
testing.expectEqual(false, result.done);
testing.expectEqual('hi', result.value);
const result2 = await reader.read();
testing.expectEqual(true, result2.done);
})();
</script>

View File

@@ -0,0 +1,164 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=transform_stream_basic>
{
const ts = new TransformStream();
testing.expectEqual('object', typeof ts);
testing.expectEqual('object', typeof ts.readable);
testing.expectEqual('object', typeof ts.writable);
}
</script>
<script id=transform_stream_with_transformer>
(async function() {
const ts = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
}
});
const writer = ts.writable.getWriter();
const reader = ts.readable.getReader();
await writer.write('hello');
await writer.close();
const result = await reader.read();
testing.expectEqual(false, result.done);
testing.expectEqual('HELLO', result.value);
const result2 = await reader.read();
testing.expectEqual(true, result2.done);
})();
</script>
<script id=writable_stream_basic>
{
const ws = new WritableStream();
testing.expectEqual('object', typeof ws);
testing.expectEqual(false, ws.locked);
}
</script>
<script id=writable_stream_writer>
{
const ws = new WritableStream();
const writer = ws.getWriter();
testing.expectEqual('object', typeof writer);
testing.expectEqual(true, ws.locked);
}
</script>
<script id=writable_stream_writer_desired_size>
{
const ws = new WritableStream();
const writer = ws.getWriter();
testing.expectEqual(1, writer.desiredSize);
}
</script>
<script id=text_encoder_stream_encoding>
{
const tes = new TextEncoderStream();
testing.expectEqual('utf-8', tes.encoding);
testing.expectEqual('object', typeof tes.readable);
testing.expectEqual('object', typeof tes.writable);
}
</script>
<script id=text_encoder_stream_encode>
(async function() {
const tes = new TextEncoderStream();
const writer = tes.writable.getWriter();
const reader = tes.readable.getReader();
await writer.write('hi');
await writer.close();
const result = await reader.read();
testing.expectEqual(false, result.done);
testing.expectEqual(true, result.value instanceof Uint8Array);
// 'hi' in UTF-8 is [104, 105]
testing.expectEqual(104, result.value[0]);
testing.expectEqual(105, result.value[1]);
testing.expectEqual(2, result.value.length);
const result2 = await reader.read();
testing.expectEqual(true, result2.done);
})();
</script>
<script id=pipe_through_basic>
(async function() {
const input = new ReadableStream({
start(controller) {
controller.enqueue('hello');
controller.close();
}
});
const ts = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
}
});
const output = input.pipeThrough(ts);
const reader = output.getReader();
const result = await reader.read();
testing.expectEqual(false, result.done);
testing.expectEqual('HELLO', result.value);
const result2 = await reader.read();
testing.expectEqual(true, result2.done);
})();
</script>
<script id=pipe_to_basic>
(async function() {
const chunks = [];
const input = new ReadableStream({
start(controller) {
controller.enqueue('a');
controller.enqueue('b');
controller.close();
}
});
const ws = new WritableStream({
write(chunk) {
chunks.push(chunk);
}
});
await input.pipeTo(ws);
testing.expectEqual(2, chunks.length);
testing.expectEqual('a', chunks[0]);
testing.expectEqual('b', chunks[1]);
})();
</script>
<script id=pipe_through_text_decoder>
(async function() {
const bytes = new Uint8Array([104, 101, 108, 108, 111]);
const input = new ReadableStream({
start(controller) {
controller.enqueue(bytes);
controller.close();
}
});
const output = input.pipeThrough(new TextDecoderStream());
const reader = output.getReader();
const result = await reader.read();
testing.expectEqual(false, result.done);
testing.expectEqual('hello', result.value);
const result2 = await reader.read();
testing.expectEqual(true, result2.done);
})();
</script>

View File

@@ -118,7 +118,7 @@
BASE_URL: 'http://127.0.0.1:9582/src/browser/tests/',
};
if (!IS_TEST_RUNNER) {
if (window.navigator.userAgent.startsWith("Lightpanda/") == false) {
// The page is running in a different browser. Probably a developer making sure
// a test is correct. There are a few tweaks we need to do to make this a
// seemless, namely around adapting paths/urls.

View File

@@ -218,6 +218,106 @@
testing.expectEqual('', url.password);
}
{
const url = new URL('https://example.com/path');
url.username = 'newuser';
testing.expectEqual('newuser', url.username);
testing.expectEqual('https://newuser@example.com/path', url.href);
}
{
const url = new URL('https://olduser@example.com/path');
url.username = 'newuser';
testing.expectEqual('newuser', url.username);
testing.expectEqual('https://newuser@example.com/path', url.href);
}
{
const url = new URL('https://olduser:pass@example.com/path');
url.username = 'newuser';
testing.expectEqual('newuser', url.username);
testing.expectEqual('pass', url.password);
testing.expectEqual('https://newuser:pass@example.com/path', url.href);
}
{
const url = new URL('https://user@example.com/path');
url.password = 'secret';
testing.expectEqual('user', url.username);
testing.expectEqual('secret', url.password);
testing.expectEqual('https://user:secret@example.com/path', url.href);
}
{
const url = new URL('https://user:oldpass@example.com/path');
url.password = 'newpass';
testing.expectEqual('user', url.username);
testing.expectEqual('newpass', url.password);
testing.expectEqual('https://user:newpass@example.com/path', url.href);
}
{
const url = new URL('https://user:pass@example.com/path');
url.username = '';
url.password = '';
testing.expectEqual('', url.username);
testing.expectEqual('', url.password);
testing.expectEqual('https://example.com/path', url.href);
}
{
const url = new URL('https://example.com/path');
url.username = 'user@domain';
testing.expectEqual('user%40domain', url.username);
testing.expectEqual('https://user%40domain@example.com/path', url.href);
}
{
const url = new URL('https://example.com/path');
url.username = 'user:name';
testing.expectEqual('user%3Aname', url.username);
}
{
const url = new URL('https://example.com/path');
url.password = 'pass@word';
testing.expectEqual('pass%40word', url.password);
}
{
const url = new URL('https://example.com/path');
url.password = 'pass:word';
testing.expectEqual('pass%3Aword', url.password);
}
{
const url = new URL('https://example.com/path');
url.username = 'user/name';
testing.expectEqual('user%2Fname', url.username);
}
{
const url = new URL('https://example.com/path');
url.password = 'pass?word';
testing.expectEqual('pass%3Fword', url.password);
}
{
const url = new URL('https://user%40domain:pass%3Aword@example.com/path');
testing.expectEqual('user%40domain', url.username);
testing.expectEqual('pass%3Aword', url.password);
}
{
const url = new URL('https://example.com:8080/path?a=b#hash');
url.username = 'user';
url.password = 'pass';
testing.expectEqual('https://user:pass@example.com:8080/path?a=b#hash', url.href);
testing.expectEqual('8080', url.port);
testing.expectEqual('?a=b', url.search);
testing.expectEqual('#hash', url.hash);
}
{
const url = new URL('http://user:pass@example.com:8080/path?query=1#hash');
testing.expectEqual('http:', url.protocol);
@@ -437,9 +537,9 @@
{
const url = new URL('https://example.com:8080/path');
url.host = 'newhost.com';
testing.expectEqual('https://newhost.com/path', url.href);
testing.expectEqual('https://newhost.com:8080/path', url.href);
testing.expectEqual('newhost.com', url.hostname);
testing.expectEqual('', url.port);
testing.expectEqual('8080', url.port);
}
{

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<body onload=func1></body>
<body onload="func1(event)"></body>
<script src="../testing.js"></script>
<script id=bodyOnLoad1>
@@ -14,4 +14,3 @@
testing.expectEqual(1, called);
});
</script>

View File

@@ -82,7 +82,7 @@
testing.expectEqual('ceil', atob('Y2VpbA')); // 6 chars, len%4==2, needs '=='
// length % 4 == 1 must still throw
testing.expectError('Error: InvalidCharacterError', () => {
testing.expectError('InvalidCharacterError: Invalid Character', () => {
atob('Y');
});
</script>
@@ -115,6 +115,30 @@
}
</script>
<script id=structuredClone>
// Basic types
testing.expectEqual(42, structuredClone(42));
testing.expectEqual('hello', structuredClone('hello'));
testing.expectEqual(true, structuredClone(true));
testing.expectEqual(null, structuredClone(null));
// Object deep clone
const obj = { a: 1, b: { c: 2 } };
const cloned = structuredClone(obj);
testing.expectEqual(1, cloned.a);
testing.expectEqual(2, cloned.b.c);
cloned.b.c = 99;
testing.expectEqual(2, obj.b.c); // original unchanged
// Array deep clone
const arr = [1, [2, 3]];
const clonedArr = structuredClone(arr);
testing.expectEqual(1, clonedArr[0]);
testing.expectEqual(2, clonedArr[1][0]);
clonedArr[1][0] = 99;
testing.expectEqual(2, arr[1][0]); // original unchanged
</script>
<script id=screen>
testing.expectEqual(1920, screen.width);
testing.expectEqual(1080, screen.height);

View File

@@ -76,13 +76,11 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, page: *Page) !void {
}
// Dispatch abort event
const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page);
try page._event_manager.dispatchDirect(
self.asEventTarget(),
event,
self._on_abort,
.{ .context = "abort signal" },
);
const target = self.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "abort", self._on_abort)) {
const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page);
try page._event_manager.dispatchDirect(target, event, self._on_abort, .{ .context = "abort signal" });
}
}
// Static method to create an already-aborted signal

View File

@@ -33,6 +33,9 @@ _start_offset: u32,
_end_container: *Node,
_start_container: *Node,
// Intrusive linked list node for tracking live ranges on the Page.
_range_link: std.DoublyLinkedList.Node = .{},
pub const Type = union(enum) {
range: *Range,
// TODO: static_range: *StaticRange,
@@ -215,6 +218,91 @@ fn isInclusiveAncestorOf(potential_ancestor: *Node, node: *Node) bool {
return isAncestorOf(potential_ancestor, node);
}
/// Update this range's boundaries after a replaceData mutation on target.
/// All parameters are in UTF-16 code unit offsets.
pub fn updateForCharacterDataReplace(self: *AbstractRange, target: *Node, offset: u32, count: u32, data_len: u32) void {
if (self._start_container == target) {
if (self._start_offset > offset and self._start_offset <= offset + count) {
self._start_offset = offset;
} else if (self._start_offset > offset + count) {
// Use i64 intermediate to avoid u32 underflow when count > data_len
self._start_offset = @intCast(@as(i64, self._start_offset) + @as(i64, data_len) - @as(i64, count));
}
}
if (self._end_container == target) {
if (self._end_offset > offset and self._end_offset <= offset + count) {
self._end_offset = offset;
} else if (self._end_offset > offset + count) {
self._end_offset = @intCast(@as(i64, self._end_offset) + @as(i64, data_len) - @as(i64, count));
}
}
}
/// Update this range's boundaries after a splitText operation.
/// Steps 7b-7e of the DOM spec splitText algorithm.
pub fn updateForSplitText(self: *AbstractRange, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void {
// Step 7b: ranges on the original node with start > offset move to new node
if (self._start_container == target and self._start_offset > offset) {
self._start_container = new_node;
self._start_offset = self._start_offset - offset;
}
// Step 7c: ranges on the original node with end > offset move to new node
if (self._end_container == target and self._end_offset > offset) {
self._end_container = new_node;
self._end_offset = self._end_offset - offset;
}
// Step 7d: ranges on parent with start == node_index + 1 increment
if (self._start_container == parent and self._start_offset == node_index + 1) {
self._start_offset += 1;
}
// Step 7e: ranges on parent with end == node_index + 1 increment
if (self._end_container == parent and self._end_offset == node_index + 1) {
self._end_offset += 1;
}
}
/// Update this range's boundaries after a node insertion.
pub fn updateForNodeInsertion(self: *AbstractRange, parent: *Node, child_index: u32) void {
if (self._start_container == parent and self._start_offset > child_index) {
self._start_offset += 1;
}
if (self._end_container == parent and self._end_offset > child_index) {
self._end_offset += 1;
}
}
/// Update this range's boundaries after a node removal.
pub fn updateForNodeRemoval(self: *AbstractRange, parent: *Node, child: *Node, child_index: u32) void {
// Steps 4-5: ranges whose start/end is an inclusive descendant of child
// get moved to (parent, child_index).
if (isInclusiveDescendantOf(self._start_container, child)) {
self._start_container = parent;
self._start_offset = child_index;
}
if (isInclusiveDescendantOf(self._end_container, child)) {
self._end_container = parent;
self._end_offset = child_index;
}
// Steps 6-7: ranges on parent at offsets > child_index get decremented.
if (self._start_container == parent and self._start_offset > child_index) {
self._start_offset -= 1;
}
if (self._end_container == parent and self._end_offset > child_index) {
self._end_offset -= 1;
}
}
fn isInclusiveDescendantOf(node: *Node, potential_ancestor: *Node) bool {
var current: ?*Node = node;
while (current) |n| {
if (n == potential_ancestor) return true;
current = n.parentNode();
}
return false;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(AbstractRange);

View File

@@ -21,6 +21,11 @@ const Writer = std.Io.Writer;
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Mime = @import("../Mime.zig");
const Allocator = std.mem.Allocator;
/// https://w3c.github.io/FileAPI/#blob-section
/// https://developer.mozilla.org/en-US/docs/Web/API/Blob
@@ -30,6 +35,8 @@ pub const _prototype_root = true;
_type: Type,
_arena: Allocator,
/// Immutable slice of blob.
/// Note that another blob may hold a pointer/slice to this,
/// so its better to leave the deallocation of it to arena allocator.
@@ -50,26 +57,58 @@ const InitOptions = struct {
endings: []const u8 = "transparent",
};
/// Creates a new Blob.
/// Creates a new Blob (JS constructor).
pub fn init(
maybe_blob_parts: ?[]const []const u8,
maybe_options: ?InitOptions,
page: *Page,
) !*Blob {
return initWithMimeValidation(maybe_blob_parts, maybe_options, false, page);
}
/// Creates a new Blob with optional MIME validation.
/// When validate_mime is true, uses full MIME parsing (for Response/Request).
/// When false, uses simple ASCII validation per FileAPI spec (for Blob constructor).
pub fn initWithMimeValidation(
maybe_blob_parts: ?[]const []const u8,
maybe_options: ?InitOptions,
validate_mime: bool,
page: *Page,
) !*Blob {
const arena = try page.getArena(.{ .debug = "Blob" });
errdefer page.releaseArena(arena);
const options: InitOptions = maybe_options orelse .{};
// Setup MIME; This can be any string according to my observations.
const mime: []const u8 = blk: {
const t = options.type;
if (t.len == 0) {
break :blk "";
}
break :blk try page.arena.dupe(u8, t);
const buf = try arena.dupe(u8, t);
if (validate_mime) {
// Full MIME parsing per MIME sniff spec (for Content-Type headers)
_ = Mime.parse(buf) catch break :blk "";
} else {
// Simple validation per FileAPI spec (for Blob constructor):
// - If any char is outside U+0020-U+007E, return empty string
// - Otherwise lowercase
for (t) |c| {
if (c < 0x20 or c > 0x7E) {
break :blk "";
}
}
_ = std.ascii.lowerString(buf, buf);
}
break :blk buf;
};
const data = blk: {
if (maybe_blob_parts) |blob_parts| {
var w: Writer.Allocating = .init(page.arena);
var w: Writer.Allocating = .init(arena);
const use_native_endings = std.mem.eql(u8, options.endings, "native");
try writeBlobParts(&w.writer, blob_parts, use_native_endings);
@@ -79,11 +118,19 @@ pub fn init(
break :blk "";
};
return page._factory.create(Blob{
const self = try arena.create(Blob);
self.* = .{
._arena = arena,
._type = .generic,
._slice = data,
._mime = mime,
});
};
return self;
}
pub fn deinit(self: *Blob, shutdown: bool, session: *Session) void {
_ = shutdown;
session.releaseArena(self._arena);
}
const largest_vector = @max(std.simd.suggestVectorLength(u8) orelse 1, 8);
@@ -234,57 +281,31 @@ pub fn bytes(self: *const Blob, page: *Page) !js.Promise {
/// from a subset of the blob on which it's called.
pub fn slice(
self: *const Blob,
maybe_start: ?i32,
maybe_end: ?i32,
maybe_content_type: ?[]const u8,
start_: ?i32,
end_: ?i32,
content_type_: ?[]const u8,
page: *Page,
) !*Blob {
const mime: []const u8 = blk: {
if (maybe_content_type) |content_type| {
if (content_type.len == 0) {
break :blk "";
}
const data = self._slice;
break :blk try page.dupeString(content_type);
const start = blk: {
const requested_start = start_ orelse break :blk 0;
if (requested_start < 0) {
break :blk data.len -| @abs(requested_start);
}
break :blk "";
break :blk @min(data.len, @as(u31, @intCast(requested_start)));
};
const data = self._slice;
if (maybe_start) |_start| {
const start = blk: {
if (_start < 0) {
break :blk data.len -| @abs(_start);
}
const end: usize = blk: {
const requested_end = end_ orelse break :blk data.len;
if (requested_end < 0) {
break :blk @max(start, data.len -| @abs(requested_end));
}
break :blk @min(data.len, @as(u31, @intCast(_start)));
};
break :blk @min(data.len, @max(start, @as(u31, @intCast(requested_end))));
};
const end: usize = blk: {
if (maybe_end) |_end| {
if (_end < 0) {
break :blk @max(start, data.len -| @abs(_end));
}
break :blk @min(data.len, @max(start, @as(u31, @intCast(_end))));
}
break :blk data.len;
};
return page._factory.create(Blob{
._type = .generic,
._slice = data[start..end],
._mime = mime,
});
}
return page._factory.create(Blob{
._type = .generic,
._slice = data,
._mime = mime,
});
return Blob.init(&.{data[start..end]}, .{ .type = content_type_ orelse "" }, page);
}
/// Returns the size of the Blob in bytes.
@@ -304,6 +325,8 @@ pub const JsApi = struct {
pub const name = "Blob";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(Blob.deinit);
};
pub const constructor = bridge.constructor(Blob.init, .{});

View File

@@ -37,7 +37,7 @@ _data: String = .empty,
/// Count UTF-16 code units in a UTF-8 string.
/// 4-byte UTF-8 sequences (codepoints >= U+10000) produce 2 UTF-16 code units (surrogate pair),
/// everything else produces 1.
fn utf16Len(data: []const u8) usize {
pub fn utf16Len(data: []const u8) usize {
var count: usize = 0;
var i: usize = 0;
while (i < data.len) {
@@ -232,14 +232,13 @@ pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void {
}
/// JS bridge wrapper for `data` setter.
/// Handles [LegacyNullToEmptyString]: null → setData(null) → "".
/// Passes everything else (including undefined) through V8 toString,
/// so `undefined` becomes the string "undefined" per spec.
/// Per spec, setting .data runs replaceData(0, this.length, value),
/// which includes live range updates.
/// Handles [LegacyNullToEmptyString]: null → "" per spec.
pub fn _setData(self: *CData, value: js.Value, page: *Page) !void {
if (value.isNull()) {
return self.setData(null, page);
}
return self.setData(try value.toZig([]const u8), page);
const new_value: []const u8 = if (value.isNull()) "" else try value.toZig([]const u8);
const length = self.getLength();
try self.replaceData(0, length, new_value, page);
}
pub fn format(self: *const CData, writer: *std.io.Writer) !void {
@@ -272,15 +271,20 @@ pub fn isEqualNode(self: *const CData, other: *const CData) bool {
}
pub fn appendData(self: *CData, data: []const u8, page: *Page) !void {
const old_value = self._data;
self._data = try String.concat(page.arena, &.{ self._data.str(), data });
page.characterDataChange(self.asNode(), old_value);
// Per DOM spec, appendData(data) is replaceData(length, 0, data).
const length = self.getLength();
try self.replaceData(length, 0, data, page);
}
pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void {
const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize);
const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16);
// Update live ranges per DOM spec replaceData steps (deleteData = replaceData with data="")
const length = self.getLength();
const effective_count: u32 = @intCast(@min(count, length - offset));
page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, 0);
const old_data = self._data;
const old_value = old_data.str();
if (range.start == 0) {
@@ -299,6 +303,10 @@ pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void
pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !void {
const byte_offset = try utf16OffsetToUtf8(self._data.str(), offset);
// Update live ranges per DOM spec replaceData steps (insertData = replaceData with count=0)
page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), 0, @intCast(utf16Len(data)));
const old_value = self._data;
const existing = old_value.str();
self._data = try String.concat(page.arena, &.{
@@ -312,6 +320,12 @@ pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !v
pub fn replaceData(self: *CData, offset: usize, count: usize, data: []const u8, page: *Page) !void {
const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize);
const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16);
// Update live ranges per DOM spec replaceData steps
const length = self.getLength();
const effective_count: u32 = @intCast(@min(count, length - offset));
page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, @intCast(utf16Len(data)));
const old_value = self._data;
const existing = old_value.str();
self._data = try String.concat(page.arena, &.{

View File

@@ -90,15 +90,16 @@ pub fn parseFromString(
return pe.err;
}
// If first node is a `ProcessingInstruction`, skip it.
const first_child = doc_node.firstChild() orelse {
// Parsing should fail if there aren't any nodes.
unreachable;
// Empty XML or no root element - this is a parse error.
// TODO: Return a document with a <parsererror> element per spec.
return error.JsException;
};
// If first node is a `ProcessingInstruction`, skip it.
if (first_child.getNodeType() == 7) {
// We're sure that firstChild exist, this cannot fail.
_ = doc_node.removeChild(first_child, page) catch unreachable;
_ = try doc_node.removeChild(first_child, page);
}
return doc.asDocument();

View File

@@ -40,6 +40,8 @@ const Selection = @import("Selection.zig");
pub const XMLDocument = @import("XMLDocument.zig");
pub const HTMLDocument = @import("HTMLDocument.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
const Document = @This();
_type: Type,
@@ -937,6 +939,32 @@ fn validateElementName(name: []const u8) !void {
}
}
// When a page or frame's URL is about:blank, or as soon as a frame is
// programmatically created, it has this default "blank" content
pub fn injectBlank(self: *Document, page: *Page) error{InjectBlankError}!void {
self._injectBlank(page) catch |err| {
// we wrap _injectBlank like this so that injectBlank can only return an
// InjectBlankError. injectBlank is used in when nodes are inserted
// as since it inserts node itself, Zig can't infer the error set.
log.err(.browser, "inject blank", .{ .err = err });
return error.InjectBlankError;
};
}
fn _injectBlank(self: *Document, page: *Page) !void {
if (comptime IS_DEBUG) {
// should only be called on an empty document
std.debug.assert(self.asNode()._children == null);
}
const html = try page.createElementNS(.html, "html", null);
const head = try page.createElementNS(.html, "head", null);
const body = try page.createElementNS(.html, "body", null);
try page.appendNode(html, head, .{});
try page.appendNode(html, body, .{});
try page.appendNode(self.asNode(), html, .{});
}
const ReadyState = enum {
loading,
interactive,

View File

@@ -195,8 +195,9 @@ pub fn cloneFragment(self: *DocumentFragment, deep: bool, page: *Page) !*Node {
var child_it = node.childrenIterator();
while (child_it.next()) |child| {
const cloned_child = try child.cloneNode(true, page);
try page.appendNode(fragment_node, cloned_child, .{ .child_already_connected = self_is_connected });
if (try child.cloneNodeForAppending(true, page)) |cloned_child| {
try page.appendNode(fragment_node, cloned_child, .{ .child_already_connected = self_is_connected });
}
}
}

View File

@@ -209,6 +209,7 @@ pub fn getTagNameLower(self: *const Element) []const u8 {
.custom => |e| e._tag_name.str(),
.data => "data",
.datalist => "datalist",
.details => "details",
.dialog => "dialog",
.directory => "dir",
.div => "div",
@@ -287,6 +288,7 @@ pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 {
.custom => |e| upperTagName(&e._tag_name, buf),
.data => "DATA",
.datalist => "DATALIST",
.details => "DETAILS",
.dialog => "DIALOG",
.directory => "DIR",
.div => "DIV",
@@ -1326,11 +1328,12 @@ pub fn clone(self: *Element, deep: bool, page: *Page) !*Node {
if (deep) {
var child_it = self.asNode().childrenIterator();
while (child_it.next()) |child| {
const cloned_child = try child.cloneNode(true, page);
// We pass `true` to `child_already_connected` as a hacky optimization
// We _know_ this child isn't connected (Becasue the parent isn't connected)
// setting this to `true` skips all connection checks and just assumes t
try page.appendNode(node, cloned_child, .{ .child_already_connected = true });
if (try child.cloneNodeForAppending(true, page)) |cloned_child| {
// We pass `true` to `child_already_connected` as a hacky optimization
// We _know_ this child isn't connected (Because the parent isn't connected)
// setting this to `true` skips all connection checks.
try page.appendNode(node, cloned_child, .{ .child_already_connected = true });
}
}
}
@@ -1385,6 +1388,7 @@ pub fn getTag(self: *const Element) Tag {
.custom => .custom,
.data => .data,
.datalist => .datalist,
.details => .details,
.dialog => .dialog,
.directory => .directory,
.iframe => .iframe,

View File

@@ -20,6 +20,7 @@ const std = @import("std");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const EventTarget = @import("EventTarget.zig");
const Node = @import("Node.zig");
const String = @import("../../string.zig").String;
@@ -139,9 +140,9 @@ pub fn acquireRef(self: *Event) void {
self._rc += 1;
}
pub fn deinit(self: *Event, shutdown: bool, page: *Page) void {
pub fn deinit(self: *Event, shutdown: bool, session: *Session) void {
if (shutdown) {
page.releaseArena(self._arena);
session.releaseArena(self._arena);
return;
}
@@ -151,7 +152,7 @@ pub fn deinit(self: *Event, shutdown: bool, page: *Page) void {
}
if (rc == 1) {
page.releaseArena(self._arena);
session.releaseArena(self._arena);
} else {
self._rc = rc - 1;
}

View File

@@ -59,7 +59,7 @@ pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool {
event._is_trusted = false;
event.acquireRef();
defer event.deinit(false, page);
defer event.deinit(false, page._session);
try page._event_manager.dispatch(self, event);
return !event._cancelable or !event._prevent_default;
}
@@ -138,6 +138,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
.screen => writer.writeAll("<Screen>"),
.screen_orientation => writer.writeAll("<ScreenOrientation>"),
.visual_viewport => writer.writeAll("<VisualViewport>"),
.file_reader => writer.writeAll("<FileReader>"),
};
}

View File

@@ -18,9 +18,11 @@
const std = @import("std");
const Page = @import("../Page.zig");
const Blob = @import("Blob.zig");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Blob = @import("Blob.zig");
const File = @This();
@@ -29,7 +31,13 @@ _proto: *Blob,
// TODO: Implement File API.
pub fn init(page: *Page) !*File {
return page._factory.blob(File{ ._proto = undefined });
const arena = try page.getArena(.{ .debug = "File" });
errdefer page.releaseArena(arena);
return page._factory.blob(arena, File{ ._proto = undefined });
}
pub fn deinit(self: *File, shutdown: bool, session: *Session) void {
self._proto.deinit(shutdown, session);
}
pub const JsApi = struct {
@@ -39,6 +47,8 @@ pub const JsApi = struct {
pub const name = "File";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(File.deinit);
};
pub const constructor = bridge.constructor(File.init, .{});

View File

@@ -20,6 +20,7 @@ const std = @import("std");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const EventTarget = @import("EventTarget.zig");
const ProgressEvent = @import("event/ProgressEvent.zig");
const Blob = @import("Blob.zig");
@@ -69,17 +70,15 @@ pub fn init(page: *Page) !*FileReader {
});
}
pub fn deinit(self: *FileReader, _: bool, page: *Page) void {
const js_ctx = page.js;
pub fn deinit(self: *FileReader, _: bool, session: *Session) void {
if (self._on_abort) |func| func.release();
if (self._on_error) |func| func.release();
if (self._on_load) |func| func.release();
if (self._on_load_end) |func| func.release();
if (self._on_load_start) |func| func.release();
if (self._on_progress) |func| func.release();
if (self._on_abort) |func| js_ctx.release(func);
if (self._on_error) |func| js_ctx.release(func);
if (self._on_load) |func| js_ctx.release(func);
if (self._on_load_end) |func| js_ctx.release(func);
if (self._on_load_start) |func| js_ctx.release(func);
if (self._on_progress) |func| js_ctx.release(func);
page.releaseArena(self._arena);
session.releaseArena(self._arena);
}
fn asEventTarget(self: *FileReader) *EventTarget {

View File

@@ -167,9 +167,8 @@ pub fn getEmbeds(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) {
return collections.NodeLive(.tag).init(self.asNode(), .embed, page);
}
const applet_string = String.init(undefined, "applet", .{}) catch unreachable;
pub fn getApplets(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag_name) {
return collections.NodeLive(.tag_name).init(self.asNode(), applet_string, page);
pub fn getApplets(_: *const HTMLDocument) collections.HTMLCollection {
return .{ ._data = .empty };
}
pub fn getCurrentScript(self: *const HTMLDocument) ?*Element.Html.Script {
@@ -180,8 +179,8 @@ pub fn getLocation(self: *const HTMLDocument) ?*@import("Location.zig") {
return self._proto._location;
}
pub fn setLocation(_: *const HTMLDocument, url: [:0]const u8, page: *Page) !void {
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .script);
pub fn setLocation(self: *HTMLDocument, url: [:0]const u8, page: *Page) !void {
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = self._proto._page });
}
pub fn getAll(self: *HTMLDocument, page: *Page) !*collections.HTMLAllCollection {

View File

@@ -79,13 +79,11 @@ fn goInner(delta: i32, page: *Page) !void {
if (entry._url) |url| {
if (try page.isSameOrigin(url)) {
const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent();
try page._event_manager.dispatchDirect(
page.window.asEventTarget(),
event,
page.window._on_popstate,
.{ .context = "Pop State" },
);
const target = page.window.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "popstate", page.window._on_popstate)) {
const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent();
try page._event_manager.dispatchDirect(target, event, page.window._on_popstate, .{ .context = "Pop State" });
}
}
}

View File

@@ -24,6 +24,7 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
const Allocator = std.mem.Allocator;
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Element = @import("Element.zig");
const DOMRect = @import("DOMRect.zig");
@@ -91,13 +92,13 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I
return self;
}
pub fn deinit(self: *IntersectionObserver, shutdown: bool, page: *Page) void {
page.js.release(self._callback);
pub fn deinit(self: *IntersectionObserver, shutdown: bool, session: *Session) void {
self._callback.release();
if ((comptime IS_DEBUG) and !shutdown) {
std.debug.assert(self._observing.items.len == 0);
}
page.releaseArena(self._arena);
session.releaseArena(self._arena);
}
pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void {
@@ -137,7 +138,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi
while (j < self._pending_entries.items.len) {
if (self._pending_entries.items[j]._target == target) {
const entry = self._pending_entries.swapRemove(j);
entry.deinit(false, page);
entry.deinit(false, page._session);
} else {
j += 1;
}
@@ -157,7 +158,7 @@ pub fn disconnect(self: *IntersectionObserver, page: *Page) void {
self._previous_states.clearRetainingCapacity();
for (self._pending_entries.items) |entry| {
entry.deinit(false, page);
entry.deinit(false, page._session);
}
self._pending_entries.clearRetainingCapacity();
page.js.safeWeakRef(self);
@@ -302,8 +303,8 @@ pub const IntersectionObserverEntry = struct {
_intersection_ratio: f64,
_is_intersecting: bool,
pub fn deinit(self: *const IntersectionObserverEntry, _: bool, page: *Page) void {
page.releaseArena(self._arena);
pub fn deinit(self: *IntersectionObserverEntry, _: bool, session: *Session) void {
session.releaseArena(self._arena);
}
pub fn getTarget(self: *const IntersectionObserverEntry) *Element {

View File

@@ -83,19 +83,19 @@ pub fn setHash(_: *const Location, hash: []const u8, page: *Page) !void {
return page.scheduleNavigation(normalized_hash, .{
.reason = .script,
.kind = .{ .replace = null },
}, .script);
}, .{ .script = page });
}
pub fn assign(_: *const Location, url: [:0]const u8, page: *Page) !void {
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .script);
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = page });
}
pub fn replace(_: *const Location, url: [:0]const u8, page: *Page) !void {
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .replace = null } }, .script);
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .replace = null } }, .{ .script = page });
}
pub fn reload(_: *const Location, page: *Page) !void {
return page.scheduleNavigation(page.url, .{ .reason = .script, .kind = .reload }, .script);
return page.scheduleNavigation(page.url, .{ .reason = .script, .kind = .reload }, .{ .script = page });
}
pub fn toString(self: *const Location, page: *const Page) ![:0]const u8 {

View File

@@ -122,23 +122,21 @@ const PostMessageCallback = struct {
return null;
}
const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = self.message,
.origin = "",
.source = null,
}, page) catch |err| {
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
return null;
}).asEvent();
const target = self.port.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "message", self.port._on_message)) {
const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = self.message,
.origin = "",
.source = null,
}, page) catch |err| {
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
return null;
}).asEvent();
page._event_manager.dispatchDirect(
self.port.asEventTarget(),
event,
self.port._on_message,
.{ .context = "MessagePort message" },
) catch |err| {
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
};
page._event_manager.dispatchDirect(target, event, self.port._on_message, .{ .context = "MessagePort message" }) catch |err| {
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
};
}
return null;
}

View File

@@ -21,6 +21,7 @@ const String = @import("../../string.zig").String;
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Node = @import("Node.zig");
const Element = @import("Element.zig");
const log = @import("../../log.zig");
@@ -84,13 +85,13 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver {
return self;
}
pub fn deinit(self: *MutationObserver, shutdown: bool, page: *Page) void {
page.js.release(self._callback);
pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void {
self._callback.release();
if ((comptime IS_DEBUG) and !shutdown) {
std.debug.assert(self._observing.items.len == 0);
}
page.releaseArena(self._arena);
session.releaseArena(self._arena);
}
pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void {
@@ -171,7 +172,7 @@ pub fn disconnect(self: *MutationObserver, page: *Page) void {
page.unregisterMutationObserver(self);
self._observing.clearRetainingCapacity();
for (self._pending_records.items) |record| {
record.deinit(false, page);
record.deinit(false, page._session);
}
self._pending_records.clearRetainingCapacity();
page.js.safeWeakRef(self);
@@ -363,8 +364,8 @@ pub const MutationRecord = struct {
characterData,
};
pub fn deinit(self: *const MutationRecord, _: bool, page: *Page) void {
page.releaseArena(self._arena);
pub fn deinit(self: *MutationRecord, _: bool, session: *Session) void {
session.releaseArena(self._arena);
}
pub fn getType(self: *const MutationRecord) []const u8 {

View File

@@ -293,7 +293,8 @@ pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void {
}
return el.replaceChildren(&.{.{ .text = data }}, page);
},
.cdata => |c| c._data = try page.dupeSSO(data),
// Per spec, setting textContent on CharacterData runs replaceData(0, length, value)
.cdata => |c| try c.replaceData(0, c.getLength(), data, page),
.document => {},
.document_type => {},
.document_fragment => |frag| {
@@ -612,7 +613,11 @@ pub fn getNodeValue(self: *const Node) ?String {
pub fn setNodeValue(self: *const Node, value: ?String, page: *Page) !void {
switch (self._type) {
.cdata => |c| try c.setData(if (value) |v| v.str() else null, page),
// Per spec, setting nodeValue on CharacterData runs replaceData(0, length, value)
.cdata => |c| {
const new_value: []const u8 = if (value) |v| v.str() else "";
try c.replaceData(0, c.getLength(), new_value, page);
},
.attribute => |attr| try attr.setValue(value, page),
.element => {},
.document => {},
@@ -724,6 +729,9 @@ const CloneError = error{
TooManyContexts,
LinkLoadError,
StyleLoadError,
TypeError,
CompilationError,
JsException,
};
pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) CloneError!*Node {
const deep = deep_ orelse false;
@@ -751,6 +759,29 @@ pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) CloneError!*Node {
}
}
/// Clone a node for the purpose of appending to a parent.
/// Returns null if the cloned node was already attached somewhere by a custom element
/// constructor, indicating that the constructor's decision should be respected.
///
/// This helper is used when iterating over children to clone them. The typical pattern is:
/// while (child_it.next()) |child| {
/// if (try child.cloneNodeForAppending(true, page)) |cloned| {
/// try page.appendNode(parent, cloned, opts);
/// }
/// }
///
/// The only case where a cloned node would already have a parent is when a custom element
/// constructor (which runs during cloning per the HTML spec) explicitly attaches the element
/// somewhere. In that case, we respect the constructor's decision and return null to signal
/// that the cloned node should not be appended to our intended parent.
pub fn cloneNodeForAppending(self: *Node, deep: bool, page: *Page) CloneError!?*Node {
const cloned = try self.cloneNode(deep, page);
if (cloned._parent != null) {
return null;
}
return cloned;
}
pub fn compareDocumentPosition(self: *Node, other: *Node) u16 {
const DISCONNECTED: u16 = 0x01;
const PRECEDING: u16 = 0x02;

View File

@@ -268,7 +268,7 @@ pub const JsApi = struct {
pub const now = bridge.function(Performance.now, .{});
pub const mark = bridge.function(Performance.mark, .{});
pub const measure = bridge.function(Performance.measure, .{});
pub const measure = bridge.function(Performance.measure, .{ .dom_exception = true });
pub const clearMarks = bridge.function(Performance.clearMarks, .{});
pub const clearMeasures = bridge.function(Performance.clearMeasures, .{});
pub const getEntries = bridge.function(Performance.getEntries, .{});

View File

@@ -25,6 +25,7 @@ const Page = @import("../Page.zig");
const Node = @import("Node.zig");
const DocumentFragment = @import("DocumentFragment.zig");
const AbstractRange = @import("AbstractRange.zig");
const DOMRect = @import("DOMRect.zig");
const Range = @This();
@@ -321,6 +322,11 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void {
const container = self._proto._start_container;
const offset = self._proto._start_offset;
// Per spec: if range is collapsed, end offset should extend to include
// the inserted node. Capture before insertion since live range updates
// in the insert path will adjust non-collapsed ranges automatically.
const was_collapsed = self._proto.getCollapsed();
if (container.is(Node.CData)) |_| {
// If container is a text node, we need to split it
const parent = container.parentNode() orelse return error.InvalidNodeType;
@@ -350,9 +356,10 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void {
_ = try container.insertBefore(node, ref_child, page);
}
// Update range to be after the inserted node
if (self._proto._start_container == self._proto._end_container) {
self._proto._end_offset += 1;
// Per spec step 11: if range was collapsed, extend end to include inserted node.
// Non-collapsed ranges are already handled by the live range update in the insert path.
if (was_collapsed) {
self._proto._end_offset = self._proto._start_offset + 1;
}
}
@@ -374,9 +381,12 @@ pub fn deleteContents(self: *Range, page: *Page) !void {
);
page.characterDataChange(self._proto._start_container, old_value);
} else {
// Delete child nodes in range
var offset = self._proto._start_offset;
while (offset < self._proto._end_offset) : (offset += 1) {
// Delete child nodes in range.
// Capture count before the loop: removeChild triggers live range
// updates that decrement _end_offset on each removal.
const count = self._proto._end_offset - self._proto._start_offset;
var i: u32 = 0;
while (i < count) : (i += 1) {
if (self._proto._start_container.getChildAt(self._proto._start_offset)) |child| {
_ = try self._proto._start_container.removeChild(child, page);
}
@@ -446,8 +456,9 @@ pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment {
var offset = self._proto._start_offset;
while (offset < self._proto._end_offset) : (offset += 1) {
if (self._proto._start_container.getChildAt(offset)) |child| {
const cloned = try child.cloneNode(true, page);
_ = try fragment.asNode().appendChild(cloned, page);
if (try child.cloneNodeForAppending(true, page)) |cloned| {
_ = try fragment.asNode().appendChild(cloned, page);
}
}
}
}
@@ -468,9 +479,11 @@ pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment {
if (self._proto._start_container.parentNode() == self._proto._end_container.parentNode()) {
var current = self._proto._start_container.nextSibling();
while (current != null and current != self._proto._end_container) {
const cloned = try current.?.cloneNode(true, page);
_ = try fragment.asNode().appendChild(cloned, page);
current = current.?.nextSibling();
const next = current.?.nextSibling();
if (try current.?.cloneNodeForAppending(true, page)) |cloned| {
_ = try fragment.asNode().appendChild(cloned, page);
}
current = next;
}
}
@@ -640,6 +653,33 @@ fn nextAfterSubtree(node: *Node, root: *Node) ?*Node {
return null;
}
pub fn getBoundingClientRect(self: *const Range, page: *Page) DOMRect {
if (self._proto.getCollapsed()) {
return .{ ._x = 0, ._y = 0, ._width = 0, ._height = 0 };
}
const element = self.getContainerElement() orelse {
return .{ ._x = 0, ._y = 0, ._width = 0, ._height = 0 };
};
return element.getBoundingClientRect(page);
}
pub fn getClientRects(self: *const Range, page: *Page) ![]DOMRect {
if (self._proto.getCollapsed()) {
return &.{};
}
const element = self.getContainerElement() orelse {
return &.{};
};
return element.getClientRects(page);
}
fn getContainerElement(self: *const Range) ?*Node.Element {
const container = self._proto.getCommonAncestorContainer();
if (container.is(Node.Element)) |el| return el;
const parent = container.parentNode() orelse return null;
return parent.is(Node.Element);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Range);
@@ -678,9 +718,14 @@ pub const JsApi = struct {
pub const surroundContents = bridge.function(Range.surroundContents, .{ .dom_exception = true });
pub const createContextualFragment = bridge.function(Range.createContextualFragment, .{ .dom_exception = true });
pub const toString = bridge.function(Range.toString, .{ .dom_exception = true });
pub const getBoundingClientRect = bridge.function(Range.getBoundingClientRect, .{});
pub const getClientRects = bridge.function(Range.getClientRects, .{});
};
const testing = @import("../../testing.zig");
test "WebApi: Range" {
try testing.htmlRunner("range.html", .{});
}
test "WebApi: Range mutations" {
try testing.htmlRunner("range_mutations.html", .{});
}

View File

@@ -31,6 +31,7 @@ const Mode = enum {
pub fn TreeWalker(comptime mode: Mode) type {
return struct {
_current: ?*Node = null,
_next: ?*Node,
_root: *Node,
@@ -47,37 +48,46 @@ pub fn TreeWalker(comptime mode: Mode) type {
pub fn next(self: *Self) ?*Node {
const node = self._next orelse return null;
self._current = node;
if (comptime mode == .children) {
self._next = Node.linkToNodeOrNull(node._child_link.next);
self._next = node.nextSibling();
return node;
}
if (node._children) |children| {
self._next = children.first();
} else if (node._child_link.next) |n| {
self._next = Node.linkToNode(n);
if (node.firstChild()) |child| {
self._next = child;
} else {
// No children, no next sibling - walk up until we find a next sibling or hit root
var current = node._parent;
while (current) |parent| {
if (parent == self._root) {
self._next = null;
break;
var current: *Node = node;
while (current != self._root) {
if (current.nextSibling()) |sibling| {
self._next = sibling;
return node;
}
if (parent._child_link.next) |next_sibling| {
self._next = Node.linkToNode(next_sibling);
break;
}
current = parent._parent;
} else {
self._next = null;
current = current._parent orelse break;
}
self._next = null;
}
return node;
}
pub fn skipChildren(self: *Self) void {
if (comptime mode == .children) return;
const current_node = self._current orelse return;
var current: *Node = current_node;
while (current != self._root) {
if (current.nextSibling()) |sibling| {
self._next = sibling;
return;
}
current = current._parent orelse break;
}
self._next = null;
}
pub fn reset(self: *Self) void {
self._current = null;
self._next = firstNext(self._root);
}
@@ -147,3 +157,38 @@ pub fn TreeWalker(comptime mode: Mode) type {
};
};
}
test "TreeWalker: skipChildren" {
const testing = @import("../../testing.zig");
const page = try testing.test_session.createPage();
defer testing.test_session.removePage();
const doc = page.window._document;
// <div>
// <span>
// <b>A</b>
// </span>
// <p>B</p>
// </div>
const div = try doc.createElement("div", null, page);
const span = try doc.createElement("span", null, page);
const b = try doc.createElement("b", null, page);
const p = try doc.createElement("p", null, page);
_ = try span.asNode().appendChild(b.asNode(), page);
_ = try div.asNode().appendChild(span.asNode(), page);
_ = try div.asNode().appendChild(p.asNode(), page);
var tw = Full.init(div.asNode(), .{});
// root (div)
try testing.expect(tw.next() == div.asNode());
// span
try testing.expect(tw.next() == span.asNode());
// skip children of span (should jump over <b> to <p>)
tw.skipChildren();
try testing.expect(tw.next() == p.asNode());
try testing.expect(tw.next() == null);
}

View File

@@ -66,10 +66,20 @@ pub fn getUsername(self: *const URL) []const u8 {
return U.getUsername(self._raw);
}
pub fn setUsername(self: *URL, value: []const u8) !void {
const allocator = self._arena orelse return error.NoAllocator;
self._raw = try U.setUsername(self._raw, value, allocator);
}
pub fn getPassword(self: *const URL) []const u8 {
return U.getPassword(self._raw);
}
pub fn setPassword(self: *URL, value: []const u8) !void {
const allocator = self._arena orelse return error.NoAllocator;
self._raw = try U.setPassword(self._raw, value, allocator);
}
pub fn getPathname(self: *const URL) []const u8 {
return U.getPathname(self._raw);
}
@@ -233,11 +243,10 @@ pub fn createObjectURL(blob: *Blob, page: *Page) ![]const u8 {
var uuid_buf: [36]u8 = undefined;
@import("../../id.zig").uuidv4(&uuid_buf);
const origin = (try page.getOrigin(page.call_arena)) orelse "null";
const blob_url = try std.fmt.allocPrint(
page.arena,
"blob:{s}/{s}",
.{ origin, uuid_buf },
.{ page.origin orelse "null", uuid_buf },
);
try page._blob_urls.put(page.arena, blob_url, blob);
return blob_url;
@@ -272,8 +281,8 @@ pub const JsApi = struct {
pub const search = bridge.accessor(URL.getSearch, URL.setSearch, .{});
pub const hash = bridge.accessor(URL.getHash, URL.setHash, .{});
pub const pathname = bridge.accessor(URL.getPathname, URL.setPathname, .{});
pub const username = bridge.accessor(URL.getUsername, null, .{});
pub const password = bridge.accessor(URL.getPassword, null, .{});
pub const username = bridge.accessor(URL.getUsername, URL.setUsername, .{});
pub const password = bridge.accessor(URL.getPassword, URL.setPassword, .{});
pub const hostname = bridge.accessor(URL.getHostname, URL.setHostname, .{});
pub const host = bridge.accessor(URL.getHost, URL.setHost, .{});
pub const port = bridge.accessor(URL.getPort, URL.setPort, .{});

View File

@@ -160,8 +160,8 @@ pub fn getSelection(self: *const Window) *Selection {
return &self._document._selection;
}
pub fn setLocation(_: *const Window, url: [:0]const u8, page: *Page) !void {
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .script);
pub fn setLocation(self: *Window, url: [:0]const u8, page: *Page) !void {
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = self._page });
}
pub fn getHistory(_: *Window, page: *Page) *History {
@@ -412,6 +412,18 @@ pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 {
return decoded;
}
pub fn structuredClone(_: *const Window, value: js.Value) !js.Value {
// Simplified structured clone using JSON round-trip.
// Handles JSON-serializable types (objects, arrays, strings, numbers, booleans, null).
const local = value.local;
const str_handle = js.v8.v8__JSON__Stringify(local.handle, value.handle, null) orelse return error.DataCloneError;
const cloned_handle = js.v8.v8__JSON__Parse(local.handle, str_handle) orelse return error.DataCloneError;
return js.Value{
.local = local,
.handle = cloned_handle,
};
}
pub fn getFrame(self: *Window, idx: usize) !?*Window {
const page = self._page;
const frames = page.frames.items;
@@ -551,17 +563,14 @@ pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection,
});
}
const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{
.reason = if (rejection.reason()) |r| try r.temp() else null,
.promise = try rejection.promise().temp(),
}, page)).asEvent();
try page._event_manager.dispatchDirect(
self.asEventTarget(),
event,
self._on_unhandled_rejection,
.{ .inject_target = true, .context = "window.unhandledrejection" },
);
const target = self.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "unhandledrejection", self._on_unhandled_rejection)) {
const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{
.reason = if (rejection.reason()) |r| try r.temp() else null,
.promise = try rejection.promise().temp(),
}, page)).asEvent();
try page._event_manager.dispatchDirect(target, event, self._on_unhandled_rejection, .{ .context = "window.unhandledrejection" });
}
}
const ScheduleOpts = struct {
@@ -649,9 +658,9 @@ const ScheduleCallback = struct {
}
fn deinit(self: *ScheduleCallback) void {
self.page.js.release(self.cb);
self.cb.release();
for (self.params) |param| {
self.page.js.release(param);
param.release();
}
self.page.releaseArena(self.arena);
}
@@ -798,8 +807,9 @@ pub const JsApi = struct {
pub const matchMedia = bridge.function(Window.matchMedia, .{});
pub const postMessage = bridge.function(Window.postMessage, .{});
pub const btoa = bridge.function(Window.btoa, .{});
pub const atob = bridge.function(Window.atob, .{});
pub const atob = bridge.function(Window.atob, .{ .dom_exception = true });
pub const reportError = bridge.function(Window.reportError, .{});
pub const structuredClone = bridge.function(Window.structuredClone, .{});
pub const getComputedStyle = bridge.function(Window.getComputedStyle, .{});
pub const getSelection = bridge.function(Window.getSelection, .{});

View File

@@ -20,6 +20,7 @@ const std = @import("std");
const log = @import("../../../log.zig");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const Allocator = std.mem.Allocator;
@@ -61,8 +62,8 @@ pub fn init(page: *Page) !*Animation {
return self;
}
pub fn deinit(self: *Animation, _: bool, page: *Page) void {
page.releaseArena(self._arena);
pub fn deinit(self: *Animation, _: bool, session: *Session) void {
session.releaseArena(self._arena);
}
pub fn play(self: *Animation, page: *Page) !void {

View File

@@ -43,16 +43,26 @@ pub fn splitText(self: *Text, offset: usize, page: *Page) !*Text {
const new_node = try page.createTextNode(new_data);
const new_text = new_node.as(Text);
const old_data = data[0..byte_offset];
try self._proto.setData(old_data, page);
// If this node has a parent, insert the new node right after this one
const node = self._proto.asNode();
// Per DOM spec splitText: insert first (step 7a), then update ranges (7b-7e),
// then truncate original node (step 8).
if (node.parentNode()) |parent| {
const next_sibling = node.nextSibling();
_ = try parent.insertBefore(new_node, next_sibling, page);
// splitText-specific range updates (steps 7b-7e)
if (parent.getChildIndex(node)) |node_index| {
page.updateRangesForSplitText(node, new_node, @intCast(offset), parent, node_index);
}
}
// Step 8: truncate original node via replaceData(offset, count, "").
// Use replaceData instead of setData so live range updates fire
// (matters for detached text nodes where steps 7b-7e were skipped).
const length = self._proto.getLength();
try self._proto.replaceData(offset, length - offset, "", page);
return new_text;
}

View File

@@ -20,14 +20,15 @@ pub const NodeLive = @import("collections/node_live.zig").NodeLive;
pub const ChildNodes = @import("collections/ChildNodes.zig");
pub const DOMTokenList = @import("collections/DOMTokenList.zig");
pub const RadioNodeList = @import("collections/RadioNodeList.zig");
pub const HTMLCollection = @import("collections/HTMLCollection.zig");
pub const HTMLAllCollection = @import("collections/HTMLAllCollection.zig");
pub const HTMLOptionsCollection = @import("collections/HTMLOptionsCollection.zig");
pub const HTMLFormControlsCollection = @import("collections/HTMLFormControlsCollection.zig");
pub fn registerTypes() []const type {
return &.{
@import("collections/HTMLCollection.zig"),
@import("collections/HTMLCollection.zig").Iterator,
HTMLCollection,
HTMLCollection.Iterator,
@import("collections/NodeList.zig"),
@import("collections/NodeList.zig").KeyIterator,
@import("collections/NodeList.zig").ValueIterator,

View File

@@ -20,6 +20,7 @@ const std = @import("std");
const Node = @import("../Node.zig");
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const GenericIterator = @import("iterator.zig").Entry;
// Optimized for node.childNodes, which has to be a live list.
@@ -53,8 +54,8 @@ pub fn init(node: *Node, page: *Page) !*ChildNodes {
return self;
}
pub fn deinit(self: *const ChildNodes, page: *Page) void {
page.releaseArena(self._arena);
pub fn deinit(self: *const ChildNodes, session: *Session) void {
session.releaseArena(self._arena);
}
pub fn length(self: *ChildNodes, page: *Page) !u32 {

View File

@@ -36,6 +36,7 @@ const Mode = enum {
links,
anchors,
form,
empty,
};
const HTMLCollection = @This();
@@ -52,22 +53,26 @@ _data: union(Mode) {
links: NodeLive(.links),
anchors: NodeLive(.anchors),
form: NodeLive(.form),
empty: void,
},
pub fn length(self: *HTMLCollection, page: *const Page) u32 {
return switch (self._data) {
.empty => 0,
inline else => |*impl| impl.length(page),
};
}
pub fn getAtIndex(self: *HTMLCollection, index: usize, page: *const Page) ?*Element {
return switch (self._data) {
.empty => null,
inline else => |*impl| impl.getAtIndex(index, page),
};
}
pub fn getByName(self: *HTMLCollection, name: []const u8, page: *Page) ?*Element {
return switch (self._data) {
.empty => null,
inline else => |*impl| impl.getByName(name, page),
};
}
@@ -87,6 +92,7 @@ pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator {
.links => |*impl| .{ .links = impl._tw.clone() },
.anchors => |*impl| .{ .anchors = impl._tw.clone() },
.form => |*impl| .{ .form = impl._tw.clone() },
.empty => .empty,
},
}, page);
}
@@ -106,6 +112,7 @@ pub const Iterator = GenericIterator(struct {
links: TreeWalker.FullExcludeSelf,
anchors: TreeWalker.FullExcludeSelf,
form: TreeWalker.FullExcludeSelf,
empty: void,
},
pub fn next(self: *@This(), _: *Page) ?*Element {
@@ -121,6 +128,7 @@ pub const Iterator = GenericIterator(struct {
.links => |*impl| impl.nextTw(&self.tw.links),
.anchors => |*impl| impl.nextTw(&self.tw.anchors),
.form => |*impl| impl.nextTw(&self.tw.form),
.empty => return null,
};
}
}, null);

View File

@@ -21,6 +21,7 @@ const std = @import("std");
const log = @import("../../../log.zig");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const Node = @import("../Node.zig");
const ChildNodes = @import("ChildNodes.zig");
@@ -38,7 +39,7 @@ _data: union(enum) {
},
_rc: usize = 0,
pub fn deinit(self: *NodeList, _: bool, page: *Page) void {
pub fn deinit(self: *NodeList, _: bool, session: *Session) void {
const rc = self._rc;
if (rc > 1) {
self._rc = rc - 1;
@@ -46,8 +47,8 @@ pub fn deinit(self: *NodeList, _: bool, page: *Page) void {
}
switch (self._data) {
.selector_list => |list| list.deinit(page),
.child_nodes => |cn| cn.deinit(page),
.selector_list => |list| list.deinit(session),
.child_nodes => |cn| cn.deinit(session),
else => {},
}
}
@@ -118,8 +119,8 @@ const Iterator = struct {
const Entry = struct { u32, *Node };
pub fn deinit(self: *Iterator, shutdown: bool, page: *Page) void {
self.list.deinit(shutdown, page);
pub fn deinit(self: *Iterator, shutdown: bool, session: *Session) void {
self.list.deinit(shutdown, session);
}
pub fn acquireRef(self: *Iterator) void {

View File

@@ -19,6 +19,7 @@
const std = @import("std");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
const R = reflect(Inner, field);
@@ -39,9 +40,9 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
return page._factory.create(Self{ .inner = inner });
}
pub fn deinit(self: *Self, shutdown: bool, page: *Page) void {
pub fn deinit(self: *Self, shutdown: bool, session: *Session) void {
if (@hasDecl(Inner, "deinit")) {
self.inner.deinit(shutdown, page);
self.inner.deinit(shutdown, session);
}
}

View File

@@ -219,7 +219,14 @@ pub fn NodeLive(comptime mode: Mode) type {
switch (mode) {
.tag => {
const el = node.is(Element) orelse return false;
return el.getTag() == self._filter;
// For HTML namespace elements, we can use the optimized tag comparison.
// For other namespaces (XML, SVG custom elements, etc.), fall back to string comparison.
if (el._namespace == .html) {
return el.getTag() == self._filter;
}
// For non-HTML elements, compare by tag name string
const element_tag = el.getTagNameLower();
return std.mem.eql(u8, element_tag, @tagName(self._filter));
},
.tag_name => {
// If we're in `tag_name` mode, then the tag_name isn't

View File

@@ -26,6 +26,8 @@ const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Element = @import("../Element.zig");
const Allocator = std.mem.Allocator;
const CSSStyleDeclaration = @This();
_element: ?*Element = null,
@@ -114,9 +116,12 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value:
const normalized = normalizePropertyName(property_name, &page.buf);
// Normalize the value for canonical serialization
const normalized_value = try normalizePropertyValue(page.call_arena, normalized, value);
// Find existing property
if (self.findProperty(normalized)) |existing| {
existing._value = try String.init(page.arena, value, .{});
existing._value = try String.init(page.arena, normalized_value, .{});
existing._important = important;
return;
}
@@ -125,7 +130,7 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value:
const prop = try page._factory.create(Property{
._node = .{},
._name = try String.init(page.arena, normalized, .{}),
._value = try String.init(page.arena, value, .{}),
._value = try String.init(page.arena, normalized_value, .{}),
._important = important,
});
self._properties.append(&prop._node);
@@ -227,6 +232,395 @@ fn normalizePropertyName(name: []const u8, buf: []u8) []const u8 {
return std.ascii.lowerString(buf, name);
}
// Normalize CSS property values for canonical serialization
fn normalizePropertyValue(arena: Allocator, property_name: []const u8, value: []const u8) ![]const u8 {
// Per CSSOM spec, unitless zero in length properties should serialize as "0px"
if (std.mem.eql(u8, value, "0") and isLengthProperty(property_name)) {
return "0px";
}
// "first baseline" serializes canonically as "baseline" (first is the default)
if (std.ascii.startsWithIgnoreCase(value, "first baseline")) {
if (value.len == 14) {
// Exact match "first baseline"
return "baseline";
}
if (value.len > 14 and value[14] == ' ') {
// "first baseline X" -> "baseline X"
return try std.mem.concat(arena, u8, &.{ "baseline", value[14..] });
}
}
// For 2-value shorthand properties, collapse "X X" to "X"
if (isTwoValueShorthand(property_name)) {
if (collapseDuplicateValue(value)) |single| {
return single;
}
}
// Canonicalize anchor-size() function: anchor name (dashed ident) comes before size keyword
if (std.mem.indexOf(u8, value, "anchor-size(") != null) {
return try canonicalizeAnchorSize(arena, value);
}
return value;
}
// Canonicalize anchor-size() so that the dashed ident (anchor name) comes before the size keyword.
// e.g. "anchor-size(width --foo)" -> "anchor-size(--foo width)"
fn canonicalizeAnchorSize(arena: Allocator, value: []const u8) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(arena);
var i: usize = 0;
while (i < value.len) {
// Look for "anchor-size("
if (std.mem.startsWith(u8, value[i..], "anchor-size(")) {
try buf.writer.writeAll("anchor-size(");
i += "anchor-size(".len;
// Parse and canonicalize the arguments
i = try canonicalizeAnchorSizeArgs(value, i, &buf.writer);
} else {
try buf.writer.writeByte(value[i]);
i += 1;
}
}
return buf.written();
}
// Parse anchor-size arguments and write them in canonical order
fn canonicalizeAnchorSizeArgs(value: []const u8, start: usize, writer: *std.Io.Writer) !usize {
var i = start;
var depth: usize = 1;
// Skip leading whitespace
while (i < value.len and value[i] == ' ') : (i += 1) {}
// Collect tokens before the comma or close paren
var first_token_start: ?usize = null;
var first_token_end: usize = 0;
var second_token_start: ?usize = null;
var second_token_end: usize = 0;
var comma_pos: ?usize = null;
var token_count: usize = 0;
const args_start = i;
var in_token = false;
// First pass: find the structure of arguments before comma/closing paren at depth 1
while (i < value.len and depth > 0) {
const c = value[i];
if (c == '(') {
depth += 1;
in_token = true;
i += 1;
} else if (c == ')') {
depth -= 1;
if (depth == 0) {
if (in_token) {
if (token_count == 0) {
first_token_end = i;
} else if (token_count == 1) {
second_token_end = i;
}
}
break;
}
i += 1;
} else if (c == ',' and depth == 1) {
if (in_token) {
if (token_count == 0) {
first_token_end = i;
} else if (token_count == 1) {
second_token_end = i;
}
}
comma_pos = i;
break;
} else if (c == ' ') {
if (in_token and depth == 1) {
if (token_count == 0) {
first_token_end = i;
token_count = 1;
} else if (token_count == 1 and second_token_start != null) {
second_token_end = i;
token_count = 2;
}
in_token = false;
}
i += 1;
} else {
if (!in_token and depth == 1) {
if (token_count == 0) {
first_token_start = i;
} else if (token_count == 1) {
second_token_start = i;
}
in_token = true;
}
i += 1;
}
}
// Handle end of tokens
if (in_token and token_count == 1 and second_token_start != null) {
second_token_end = i;
token_count = 2;
} else if (in_token and token_count == 0) {
first_token_end = i;
token_count = 1;
}
// Check if we have exactly two tokens that need reordering
if (token_count == 2) {
const first_start = first_token_start orelse args_start;
const second_start = second_token_start orelse first_token_end;
const first_token = value[first_start..first_token_end];
const second_token = value[second_start..second_token_end];
// If second token is a dashed ident and first is a size keyword, swap them
if (std.mem.startsWith(u8, second_token, "--") and isAnchorSizeKeyword(first_token)) {
try writer.writeAll(second_token);
try writer.writeByte(' ');
try writer.writeAll(first_token);
} else {
// Keep original order
try writer.writeAll(first_token);
try writer.writeByte(' ');
try writer.writeAll(second_token);
}
} else if (first_token_start) |fts| {
// Single token, just copy it
try writer.writeAll(value[fts..first_token_end]);
}
// Handle comma and fallback value (may contain nested anchor-size)
if (comma_pos) |cp| {
try writer.writeAll(", ");
i = cp + 1;
// Skip whitespace after comma
while (i < value.len and value[i] == ' ') : (i += 1) {}
// Copy the fallback, recursively handling nested anchor-size
while (i < value.len and depth > 0) {
if (std.mem.startsWith(u8, value[i..], "anchor-size(")) {
try writer.writeAll("anchor-size(");
i += "anchor-size(".len;
depth += 1;
i = try canonicalizeAnchorSizeArgs(value, i, writer);
depth -= 1;
} else if (value[i] == '(') {
depth += 1;
try writer.writeByte(value[i]);
i += 1;
} else if (value[i] == ')') {
depth -= 1;
if (depth == 0) break;
try writer.writeByte(value[i]);
i += 1;
} else {
try writer.writeByte(value[i]);
i += 1;
}
}
}
// Write closing paren
try writer.writeByte(')');
return i + 1; // Skip past the closing paren
}
fn isAnchorSizeKeyword(token: []const u8) bool {
const keywords = std.StaticStringMap(void).initComptime(.{
.{ "width", {} },
.{ "height", {} },
.{ "block", {} },
.{ "inline", {} },
.{ "self-block", {} },
.{ "self-inline", {} },
});
return keywords.has(token);
}
// Check if a value is "X X" (duplicate) and return just "X"
fn collapseDuplicateValue(value: []const u8) ?[]const u8 {
const space_idx = std.mem.indexOfScalar(u8, value, ' ') orelse return null;
if (space_idx == 0 or space_idx >= value.len - 1) return null;
const first = value[0..space_idx];
const rest = std.mem.trimLeft(u8, value[space_idx + 1 ..], " ");
// Check if there's only one more value (no additional spaces)
if (std.mem.indexOfScalar(u8, rest, ' ') != null) return null;
if (std.mem.eql(u8, first, rest)) {
return first;
}
return null;
}
fn isTwoValueShorthand(name: []const u8) bool {
const shorthands = std.StaticStringMap(void).initComptime(.{
.{ "place-content", {} },
.{ "place-items", {} },
.{ "place-self", {} },
.{ "margin-block", {} },
.{ "margin-inline", {} },
.{ "padding-block", {} },
.{ "padding-inline", {} },
.{ "inset-block", {} },
.{ "inset-inline", {} },
.{ "border-block-style", {} },
.{ "border-inline-style", {} },
.{ "border-block-width", {} },
.{ "border-inline-width", {} },
.{ "border-block-color", {} },
.{ "border-inline-color", {} },
.{ "overflow", {} },
.{ "overscroll-behavior", {} },
.{ "gap", {} },
.{ "grid-gap", {} },
// Scroll
.{ "scroll-padding-block", {} },
.{ "scroll-padding-inline", {} },
.{ "scroll-snap-align", {} },
// Background/Mask
.{ "background-size", {} },
.{ "border-image-repeat", {} },
.{ "mask-repeat", {} },
.{ "mask-size", {} },
});
return shorthands.has(name);
}
fn isLengthProperty(name: []const u8) bool {
// Properties that accept <length> or <length-percentage> values
const length_properties = std.StaticStringMap(void).initComptime(.{
// Sizing
.{ "width", {} },
.{ "height", {} },
.{ "min-width", {} },
.{ "min-height", {} },
.{ "max-width", {} },
.{ "max-height", {} },
// Margins
.{ "margin", {} },
.{ "margin-top", {} },
.{ "margin-right", {} },
.{ "margin-bottom", {} },
.{ "margin-left", {} },
.{ "margin-block", {} },
.{ "margin-block-start", {} },
.{ "margin-block-end", {} },
.{ "margin-inline", {} },
.{ "margin-inline-start", {} },
.{ "margin-inline-end", {} },
// Padding
.{ "padding", {} },
.{ "padding-top", {} },
.{ "padding-right", {} },
.{ "padding-bottom", {} },
.{ "padding-left", {} },
.{ "padding-block", {} },
.{ "padding-block-start", {} },
.{ "padding-block-end", {} },
.{ "padding-inline", {} },
.{ "padding-inline-start", {} },
.{ "padding-inline-end", {} },
// Positioning
.{ "top", {} },
.{ "right", {} },
.{ "bottom", {} },
.{ "left", {} },
.{ "inset", {} },
.{ "inset-block", {} },
.{ "inset-block-start", {} },
.{ "inset-block-end", {} },
.{ "inset-inline", {} },
.{ "inset-inline-start", {} },
.{ "inset-inline-end", {} },
// Border
.{ "border-width", {} },
.{ "border-top-width", {} },
.{ "border-right-width", {} },
.{ "border-bottom-width", {} },
.{ "border-left-width", {} },
.{ "border-block-width", {} },
.{ "border-block-start-width", {} },
.{ "border-block-end-width", {} },
.{ "border-inline-width", {} },
.{ "border-inline-start-width", {} },
.{ "border-inline-end-width", {} },
.{ "border-radius", {} },
.{ "border-top-left-radius", {} },
.{ "border-top-right-radius", {} },
.{ "border-bottom-left-radius", {} },
.{ "border-bottom-right-radius", {} },
// Text
.{ "font-size", {} },
.{ "letter-spacing", {} },
.{ "word-spacing", {} },
.{ "text-indent", {} },
// Flexbox/Grid
.{ "gap", {} },
.{ "row-gap", {} },
.{ "column-gap", {} },
.{ "flex-basis", {} },
// Legacy grid aliases
.{ "grid-column-gap", {} },
.{ "grid-row-gap", {} },
// Outline
.{ "outline", {} },
.{ "outline-width", {} },
.{ "outline-offset", {} },
// Multi-column
.{ "column-rule-width", {} },
.{ "column-width", {} },
// Scroll
.{ "scroll-margin", {} },
.{ "scroll-margin-top", {} },
.{ "scroll-margin-right", {} },
.{ "scroll-margin-bottom", {} },
.{ "scroll-margin-left", {} },
.{ "scroll-padding", {} },
.{ "scroll-padding-top", {} },
.{ "scroll-padding-right", {} },
.{ "scroll-padding-bottom", {} },
.{ "scroll-padding-left", {} },
// Shapes
.{ "shape-margin", {} },
// Motion path
.{ "offset-distance", {} },
// Transforms
.{ "translate", {} },
// Animations
.{ "animation-range-end", {} },
.{ "animation-range-start", {} },
// Other
.{ "border-spacing", {} },
.{ "text-shadow", {} },
.{ "box-shadow", {} },
.{ "baseline-shift", {} },
.{ "vertical-align", {} },
.{ "text-decoration-inset", {} },
.{ "block-step-size", {} },
// Grid lanes
.{ "flow-tolerance", {} },
.{ "column-rule-edge-inset", {} },
.{ "column-rule-interior-inset", {} },
.{ "row-rule-edge-inset", {} },
.{ "row-rule-interior-inset", {} },
.{ "rule-edge-inset", {} },
.{ "rule-interior-inset", {} },
});
return length_properties.has(name);
}
fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, normalized_name: []const u8) []const u8 {
if (std.mem.eql(u8, normalized_name, "visibility")) {
return "visible";
@@ -255,7 +649,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, .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",
.body, .div, .dl, .p, .heading, .form, .button, .canvas, .details, .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";
@@ -343,3 +737,55 @@ pub const JsApi = struct {
pub const removeProperty = bridge.function(CSSStyleDeclaration.removeProperty, .{});
pub const cssFloat = bridge.accessor(CSSStyleDeclaration.getFloat, CSSStyleDeclaration.setFloat, .{});
};
const testing = @import("std").testing;
test "normalizePropertyValue: unitless zero to 0px" {
const cases = .{
.{ "width", "0", "0px" },
.{ "height", "0", "0px" },
.{ "scroll-margin-top", "0", "0px" },
.{ "scroll-padding-bottom", "0", "0px" },
.{ "column-width", "0", "0px" },
.{ "column-rule-width", "0", "0px" },
.{ "outline", "0", "0px" },
.{ "shape-margin", "0", "0px" },
.{ "offset-distance", "0", "0px" },
.{ "translate", "0", "0px" },
.{ "grid-column-gap", "0", "0px" },
.{ "grid-row-gap", "0", "0px" },
// Non-length properties should NOT normalize
.{ "opacity", "0", "0" },
.{ "z-index", "0", "0" },
};
inline for (cases) |case| {
const result = try normalizePropertyValue(testing.allocator, case[0], case[1]);
try testing.expectEqualStrings(case[2], result);
}
}
test "normalizePropertyValue: first baseline to baseline" {
const result = try normalizePropertyValue(testing.allocator, "align-items", "first baseline");
try testing.expectEqualStrings("baseline", result);
const result2 = try normalizePropertyValue(testing.allocator, "align-self", "last baseline");
try testing.expectEqualStrings("last baseline", result2);
}
test "normalizePropertyValue: collapse duplicate two-value shorthands" {
const cases = .{
.{ "overflow", "hidden hidden", "hidden" },
.{ "gap", "10px 10px", "10px" },
.{ "scroll-snap-align", "start start", "start" },
.{ "scroll-padding-block", "5px 5px", "5px" },
.{ "background-size", "auto auto", "auto" },
.{ "overscroll-behavior", "auto auto", "auto" },
// Different values should NOT collapse
.{ "overflow", "hidden scroll", "hidden scroll" },
.{ "gap", "10px 20px", "10px 20px" },
};
inline for (cases) |case| {
const result = try normalizePropertyValue(testing.allocator, case[0], case[1]);
try testing.expectEqualStrings(case[2], result);
}
}

View File

@@ -91,39 +91,224 @@ pub fn getNamed(self: *CSSStyleProperties, name: []const u8, page: *Page) ![]con
}
fn isKnownCSSProperty(dash_case: []const u8) bool {
// List of common/known CSS properties
// In a full implementation, this would include all standard CSS properties
const known_properties = std.StaticStringMap(void).initComptime(.{
// Colors & backgrounds
.{ "color", {} },
.{ "background", {} },
.{ "background-color", {} },
.{ "background-image", {} },
.{ "background-position", {} },
.{ "background-repeat", {} },
.{ "background-size", {} },
.{ "background-attachment", {} },
.{ "background-clip", {} },
.{ "background-origin", {} },
// Typography
.{ "font", {} },
.{ "font-family", {} },
.{ "font-size", {} },
.{ "font-style", {} },
.{ "font-weight", {} },
.{ "font-variant", {} },
.{ "line-height", {} },
.{ "letter-spacing", {} },
.{ "word-spacing", {} },
.{ "text-align", {} },
.{ "text-decoration", {} },
.{ "text-indent", {} },
.{ "text-transform", {} },
.{ "white-space", {} },
.{ "word-break", {} },
.{ "word-wrap", {} },
.{ "overflow-wrap", {} },
// Box model
.{ "margin", {} },
.{ "margin-top", {} },
.{ "margin-right", {} },
.{ "margin-bottom", {} },
.{ "margin-left", {} },
.{ "margin-right", {} },
.{ "margin-block", {} },
.{ "margin-block-start", {} },
.{ "margin-block-end", {} },
.{ "margin-inline", {} },
.{ "margin-inline-start", {} },
.{ "margin-inline-end", {} },
.{ "padding", {} },
.{ "padding-top", {} },
.{ "padding-right", {} },
.{ "padding-bottom", {} },
.{ "padding-left", {} },
.{ "padding-right", {} },
.{ "padding-block", {} },
.{ "padding-block-start", {} },
.{ "padding-block-end", {} },
.{ "padding-inline", {} },
.{ "padding-inline-start", {} },
.{ "padding-inline-end", {} },
// Border
.{ "border", {} },
.{ "border-width", {} },
.{ "border-style", {} },
.{ "border-color", {} },
.{ "border-top", {} },
.{ "border-top-width", {} },
.{ "border-top-style", {} },
.{ "border-top-color", {} },
.{ "border-right", {} },
.{ "border-right-width", {} },
.{ "border-right-style", {} },
.{ "border-right-color", {} },
.{ "border-bottom", {} },
.{ "border-bottom-width", {} },
.{ "border-bottom-style", {} },
.{ "border-bottom-color", {} },
.{ "border-left", {} },
.{ "border-left-width", {} },
.{ "border-left-style", {} },
.{ "border-left-color", {} },
.{ "border-radius", {} },
.{ "border-top-left-radius", {} },
.{ "border-top-right-radius", {} },
.{ "border-bottom-left-radius", {} },
.{ "border-bottom-right-radius", {} },
.{ "float", {} },
.{ "z-index", {} },
.{ "border-collapse", {} },
.{ "border-spacing", {} },
// Sizing
.{ "width", {} },
.{ "height", {} },
.{ "min-width", {} },
.{ "min-height", {} },
.{ "max-width", {} },
.{ "max-height", {} },
.{ "box-sizing", {} },
// Positioning
.{ "position", {} },
.{ "top", {} },
.{ "right", {} },
.{ "bottom", {} },
.{ "left", {} },
.{ "inset", {} },
.{ "inset-block", {} },
.{ "inset-block-start", {} },
.{ "inset-block-end", {} },
.{ "inset-inline", {} },
.{ "inset-inline-start", {} },
.{ "inset-inline-end", {} },
.{ "z-index", {} },
.{ "float", {} },
.{ "clear", {} },
// Display & visibility
.{ "display", {} },
.{ "visibility", {} },
.{ "opacity", {} },
.{ "filter", {} },
.{ "overflow", {} },
.{ "overflow-x", {} },
.{ "overflow-y", {} },
.{ "clip", {} },
.{ "clip-path", {} },
// Flexbox
.{ "flex", {} },
.{ "flex-direction", {} },
.{ "flex-wrap", {} },
.{ "flex-flow", {} },
.{ "flex-grow", {} },
.{ "flex-shrink", {} },
.{ "flex-basis", {} },
.{ "order", {} },
// Grid
.{ "grid", {} },
.{ "grid-template", {} },
.{ "grid-template-columns", {} },
.{ "grid-template-rows", {} },
.{ "grid-template-areas", {} },
.{ "grid-auto-columns", {} },
.{ "grid-auto-rows", {} },
.{ "grid-auto-flow", {} },
.{ "grid-column", {} },
.{ "grid-column-start", {} },
.{ "grid-column-end", {} },
.{ "grid-row", {} },
.{ "grid-row-start", {} },
.{ "grid-row-end", {} },
.{ "grid-area", {} },
.{ "gap", {} },
.{ "row-gap", {} },
.{ "column-gap", {} },
// Alignment (flexbox & grid)
.{ "align-content", {} },
.{ "align-items", {} },
.{ "align-self", {} },
.{ "justify-content", {} },
.{ "justify-items", {} },
.{ "justify-self", {} },
.{ "place-content", {} },
.{ "place-items", {} },
.{ "place-self", {} },
// Transforms & animations
.{ "transform", {} },
.{ "transform-origin", {} },
.{ "transform-style", {} },
.{ "perspective", {} },
.{ "perspective-origin", {} },
.{ "transition", {} },
.{ "position", {} },
.{ "top", {} },
.{ "bottom", {} },
.{ "left", {} },
.{ "right", {} },
.{ "transition-property", {} },
.{ "transition-duration", {} },
.{ "transition-timing-function", {} },
.{ "transition-delay", {} },
.{ "animation", {} },
.{ "animation-name", {} },
.{ "animation-duration", {} },
.{ "animation-timing-function", {} },
.{ "animation-delay", {} },
.{ "animation-iteration-count", {} },
.{ "animation-direction", {} },
.{ "animation-fill-mode", {} },
.{ "animation-play-state", {} },
// Filters & effects
.{ "filter", {} },
.{ "backdrop-filter", {} },
.{ "box-shadow", {} },
.{ "text-shadow", {} },
// Outline
.{ "outline", {} },
.{ "outline-width", {} },
.{ "outline-style", {} },
.{ "outline-color", {} },
.{ "outline-offset", {} },
// Lists
.{ "list-style", {} },
.{ "list-style-type", {} },
.{ "list-style-position", {} },
.{ "list-style-image", {} },
// Tables
.{ "table-layout", {} },
.{ "caption-side", {} },
.{ "empty-cells", {} },
// Misc
.{ "cursor", {} },
.{ "pointer-events", {} },
.{ "user-select", {} },
.{ "resize", {} },
.{ "object-fit", {} },
.{ "object-position", {} },
.{ "vertical-align", {} },
.{ "content", {} },
.{ "quotes", {} },
.{ "counter-reset", {} },
.{ "counter-increment", {} },
// Scrolling
.{ "scroll-behavior", {} },
.{ "scroll-margin", {} },
.{ "scroll-padding", {} },
.{ "overscroll-behavior", {} },
.{ "overscroll-behavior-x", {} },
.{ "overscroll-behavior-y", {} },
// Containment
.{ "contain", {} },
.{ "container", {} },
.{ "container-type", {} },
.{ "container-name", {} },
// Aspect ratio
.{ "aspect-ratio", {} },
});
return known_properties.has(dash_case);

View File

@@ -0,0 +1,91 @@
// 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 js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const Allocator = std.mem.Allocator;
const FontFace = @This();
_arena: Allocator,
_family: []const u8,
pub fn init(family: []const u8, source: []const u8, page: *Page) !*FontFace {
_ = source;
const arena = try page.getArena(.{ .debug = "FontFace" });
errdefer page.releaseArena(arena);
const self = try arena.create(FontFace);
self.* = .{
._arena = arena,
._family = try arena.dupe(u8, family),
};
return self;
}
pub fn deinit(self: *FontFace, _: bool, session: *Session) void {
session.releaseArena(self._arena);
}
pub fn getFamily(self: *const FontFace) []const u8 {
return self._family;
}
// load() - resolves immediately; headless browser has no real font loading.
pub fn load(_: *FontFace, page: *Page) !js.Promise {
return page.js.local.?.resolvePromise({});
}
// loaded - returns an already-resolved Promise.
pub fn getLoaded(_: *FontFace, page: *Page) !js.Promise {
return page.js.local.?.resolvePromise({});
}
pub const JsApi = struct {
pub const bridge = js.Bridge(FontFace);
pub const Meta = struct {
pub const name = "FontFace";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(FontFace.deinit);
};
pub const constructor = bridge.constructor(FontFace.init, .{});
pub const family = bridge.accessor(FontFace.getFamily, null, .{});
pub const status = bridge.property("loaded", .{ .template = false, .readonly = true });
pub const style = bridge.property("normal", .{ .template = false, .readonly = true });
pub const weight = bridge.property("normal", .{ .template = false, .readonly = true });
pub const stretch = bridge.property("normal", .{ .template = false, .readonly = true });
pub const unicodeRange = bridge.property("U+0-10FFFF", .{ .template = false, .readonly = true });
pub const variant = bridge.property("normal", .{ .template = false, .readonly = true });
pub const featureSettings = bridge.property("normal", .{ .template = false, .readonly = true });
pub const display = bridge.property("auto", .{ .template = false, .readonly = true });
pub const loaded = bridge.accessor(FontFace.getLoaded, null, .{});
pub const load = bridge.function(FontFace.load, .{});
};
const testing = @import("../../../testing.zig");
test "WebApi: FontFace" {
try testing.htmlRunner("css/font_face.html", .{});
}

View File

@@ -1,14 +1,46 @@
// 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 js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const FontFace = @import("FontFace.zig");
const Allocator = std.mem.Allocator;
const FontFaceSet = @This();
// Padding to avoid zero-size struct, which causes identity_map pointer collisions.
_pad: bool = false,
_arena: Allocator,
pub fn init(page: *Page) !*FontFaceSet {
return page._factory.create(FontFaceSet{});
const arena = try page.getArena(.{ .debug = "FontFaceSet" });
errdefer page.releaseArena(arena);
const self = try arena.create(FontFaceSet);
self.* = .{
._arena = arena,
};
return self;
}
pub fn deinit(self: *FontFaceSet, _: bool, session: *Session) void {
session.releaseArena(self._arena);
}
// FontFaceSet.ready - returns an already-resolved Promise.
@@ -29,6 +61,11 @@ pub fn load(_: *FontFaceSet, font: []const u8, page: *Page) !js.Promise {
return page.js.local.?.resolvePromise({});
}
// add(fontFace) - no-op; headless browser does not track loaded fonts.
pub fn add(self: *FontFaceSet, _: *FontFace) *FontFaceSet {
return self;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(FontFaceSet);
@@ -36,6 +73,8 @@ pub const JsApi = struct {
pub const name = "FontFaceSet";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(FontFaceSet.deinit);
};
pub const size = bridge.property(0, .{ .template = false, .readonly = true });
@@ -43,6 +82,7 @@ pub const JsApi = struct {
pub const ready = bridge.accessor(FontFaceSet.getReady, null, .{});
pub const check = bridge.function(FontFaceSet.check, .{});
pub const load = bridge.function(FontFaceSet.load, .{});
pub const add = bridge.function(FontFaceSet.add, .{});
};
const testing = @import("../../../testing.zig");

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