network/WsConnection.zig was poorly named. It didn't represent a generic WS
connection, but rather a CDP-specific connection. This splits the generic WS
logic into network/WS.zig and the CDP-specific details in cdp/Connection.zig.
Some of the connection management in the Server has also been simplified.
This takes the existing shared libcurl initiative and changes a few critical
design decisions.
1. Move to a OTP-style mailbox at both ends. Unifies all communication.
a. Network add/remove/op are all just different message types
b. Worker http/cdp/websocket are all just different message types
2. Move Network.Handle logic directly into HttpClient
a. In theory, the composition is nice, in practice, there's only 1 composer.
3. Use `curl_multi_wakeup` to wake up poll (rather than a separate `pipe`)
This is currently a WIP, and the big thing still missing is the websocket
integration.
- Depth counter when recursing
- Better comment support
- Small perf tweak (e.g. lowercase once into stack buffer before multiple
compares)
- Few more test cases
- Download rustup to a file then execute, so a failed curl is not
masked by sh's exit code under /bin/sh (no pipefail).
- Add --no-install-recommends and apt-list cleanup to both apt stages
(stage 0 drops from 156 to 116 packages, 1144 MB to 605 MB).
- Add --retry 3 --retry-delay 2 to all 4 external downloads.
- Use git clone --depth 1 (28 MB to 9.6 MB working tree).
- Drop -v from tar for minisign and zig extractions (log noise only).
Final shipped image is unchanged; the wins live in the builder stage
and build-cache footprint.
Without -Dprebuilt_v8_path, the build/test targets rebuild V8 from
source (10+ minutes per invocation). Contributors who already have a
cached archive can now short-circuit by exporting V8_PATH:
V8_PATH=v8/libc_v8.a mise exec -- make test
When V8_PATH is empty (default), behavior is unchanged.
Address review feedback on PR #2478:
- MediaQuery.zig: strip CSS `/* ... */` comments before tokenization so
`screen and /*x*/ (min-width: 1px)` evaluates the same as without the
comment.
- MediaQuery.zig: bound-check `em` / `rem` multiplication via
`std.math.mul` so a u32-overflowing length (e.g. `268435456em`) fails
closed instead of panicking in debug or wrapping in release.
- StyleManager.zig: prelude brace search skips `/* ... */` comments, so
`@media /* { fake */ screen { ... }` splits at the real opening brace
rather than the one inside the comment.
- Tests: unit tests for stripped comments, em/rem overflow, and
unimplemented units (cm/mm/pt/in/vw). HTML fixtures cover commented
preludes/queries and the `replaceSync` cascade path.
Inline `@media` rules were parsed but never applied to the cascade, and
`window.matchMedia(q).matches` always returned false. Add a Media Queries
Level 4 subset evaluator (`width`/`height`/`orientation`, lengths in
`px`/`em`/`rem`, comma OR, `and`, `not`, `only`) wired into both surfaces.
External `<link rel="stylesheet">` fetch remains out of scope; the
evaluator reads the same 1920x1080 viewport already exposed by
`Window.innerWidth` / `innerHeight`.
Closes#2477
V8's `JSON::Stringify` finishes by calling `Object::ToString` on whatever
`i::JsonStringify` returns. For values that `JSON.stringify` treats as
non-serializable at the top level (`undefined`, functions, symbols),
`i::JsonStringify` yields the undefined sentinel and `ToString` coerces
it to the JS string `"undefined"`. `Value.jsonStringify` then wrote those
9 bytes raw via `writer.writeAll`, embedding a bare `undefined` token in
the JSON stream — invalid per RFC 8259 and rejected by any strict-JSON
CDP client. Detect the sentinel and emit JSON `null` instead, matching
what `JSON.stringify` produces when the same value sits in an array slot
(`JSON.stringify([undefined])` → `"[null]"`).
Closes#2473
CDP target IDs (`FID-{d:0>10}`) must stay unique for the lifetime of
the CDP connection -- Playwright's `CRBrowser._onAttachedToTarget`
asserts on duplicates and the assertion is fatal (the connection is
unusable afterwards).
Before this fix, `Session.frame_id_gen` reset to 0 in two places:
1. `tearDownActivePage` explicitly reset to 0 after every page
teardown (likely intended to mimic pre-pending-page numbering
within a single Session, but invisible there because the
immediately-following `installNewActivePage` typically reuses
the old frame's explicit `frame_id`, see `replaceRootImmediate`).
2. Fresh Sessions started from the field default of 0. Each
`Target.createBrowserContext` calls `Browser.newSession`, which
deinits the old Session and constructs a new one -- so even
without (1), the next BrowserContext's first page would still
get `FID-0000000001`.
(2) is what trips Playwright on the second `browser.newContext()`
on a connection: the second context's first frame re-issues
`FID-0000000001`, identical to the first context's frame, and
Playwright's `CRBrowser._onAttachedToTarget` raises
`Duplicate target FID-0000000001`.
Move `frame_id_gen` (and `nextFrameId`) from `Session` to `Browser`,
which is per-CDP-connection. Existing callers (`Session.createPage`,
`Frame.zig:1327`, `Frame.zig:1437`, `Worker.zig:74`) still go through
`Session.nextFrameId` -- it's now a thin pass-through to
`browser.nextFrameId()` -- so no call sites change. Removed the
explicit reset in `tearDownActivePage`; it was redundant within a
Session (root navigation reuses the old frame_id) and harmful across
Sessions.
`loader_id_gen` stays on Session: Loader IDs (`LID-...`) are scoped
per-frame in CDP and Playwright doesn't track them in the target
registry, so the per-Session reset is correct there.
Repro (`playwright-core@1.58.2`):
for (let i = 1; i <= 3; i++) {
const ctx = await browser.newContext();
await ctx.newPage();
await ctx.close();
}
Before: cycle 2 throws `Duplicate target FID-0000000001`.
After: 5/5 cycles complete cleanly.
Tests: 653/653 pass. Added regression coverage in
`cdp.target: createTarget assigns unique IDs across BrowserContexts
(issue #2472)` -- verified to fail against the original source
(reverted Browser.zig and Session.zig, kept the test, ran zig build
test: only the new test fails).
Implements the page-side surface of the W3C WebMCP spec
(https://webmachinelearning.github.io/webmcp/): exposes
`navigator.modelContext.registerTool(...)` for declaring MCP tools to a
browser agent, with full name/description validation, AbortSignal-based
unregistration, and a `ModelContextClient` whose `requestUserInteraction`
invokes its callback directly (closest faithful behavior in a headless
browser).
This is just moving fields around. The end result is that there's a
`transfer.req` and a `transfer.res`.
On the Request side, we use to have a nested `params: RequestParam` resulting
in a lot of `transfer.req.params.url`. This is now `transfer.req.url`. On the
Response side, we had the exact opposite: response fields splattered directly
in the transfer, `transfer.response_header`. This is now `transfer.res.header`.
There is now an HttpClient.Response, which is the actual final response (which
could be for a transfer or something else, e.g the cache). And an
HttpClient.Transfer.Response which captures the inflight response data (and is
one of the polymorphic variants of the HttpClient.Response). Probably still not
ideal, but I'm not sure how to make it cleaner, and even if this is just an
intermediary step, I consider it an small win.
This allows pending destroys that have been accumulated to be cleaned up. In
normal operations, this likely isn't going to happen. But we see a some unit
tests create _many_ pages that never have the change to be cleaned up. The
result is that the next "normal" unit test, which actually runs enough through
Runner to trigger the cleanup, pays a huge cleanup price.
Arguably, for a test-only solution, we could create a session per test, or
have explicit cleanup in the test. But having 1 long-lasting session is useful
as it can show us these potential pitfalls AND, it isn't impossible that a
real-world case runs into similar issues.
CSSStyleSheet.insertRule previously detected at-rules in the parser
(has_skipped_at_rule) and returned the requested index without inserting
anything. The original change (PR #1972) did this to keep at-rule input
from killing module evaluation in apps like Expo Web -- correct, but the
silent-success contract has a second-order effect: CSS-in-JS libraries
(emotion, styled-components, Stitches, Mantine, Linaria) that round-trip
through cssRules to deduplicate their stylesheets see the rule as missing
after insertion, conclude the stylesheet is empty, and fall back to direct
<style> element injection per render. The result is unbounded <style>
accumulation on long-lived sessions doing repeated DOM interaction. See
issue #2459 for measurements (Allbirds-style PDPs accumulating thousands
of <style> elements over a render loop).
Changes:
* Parser.RulesIterator now returns a Rule union of {.style, .at_rule}
instead of skipping at-rules and setting has_skipped_at_rule. The
at-rule variant carries the keyword (without `@`) and the full source
span so callers can construct an opaque placeholder rule.
* CSSRule gains a `_text` field and an `initAtRule` constructor for
storing the at-rule source. CSSRule.getCssText returns the stored
text (CSSStyleRule's overridden getCssText still wins for `.style`
rules via the bridge dispatch on the most-derived class).
* CSSStyleSheet.insertRule and replaceSync handle both Rule variants:
regular rules become CSSStyleRule as before, at-rules become opaque
CSSRule placeholders with the matching CSSRule.Type variant
(vendor-prefixed keyframes are normalized; unrecognized at-rule
keywords fall back to .media). The CSS engine still doesn't apply
these rules -- that's intentional and outside the scope of this
change -- but they now surface via cssRules so library dedup paths
work correctly.
* StyleManager.addRawRules switches on the Rule kind and skips
at-rules (it only filters on display/visibility/opacity at the top
level, no semantic change).
* The CSSRule spec constants (STYLE_RULE, KEYFRAMES_RULE, MEDIA_RULE,
...) were declared as plain Zig consts inside JsApi but never wrapped
with bridge.property, so they came back as `undefined` from JS. Fixed
while writing the regression tests since the tests need to compare
rule.type against the spec constants.
Tests:
* Parser unit tests: cover statement at-rules (`@import url(...);`),
block at-rules (`@media`), and vendor-prefixed at-rules
(`@-webkit-keyframes`).
* HTML runner tests: cover insertRule for @keyframes, @media,
@supports, @font-face, vendor-prefixed at-rules, mixed style + at-rule
insertion, replaceSync at-rule preservation, and the dedup-via-cssRules
pattern that's the actual library code path the bug breaks.
A/B verification with the synthetic CSS-in-JS dedup pattern (50 calls to
inject the same @keyframes through a library that falls back to <style>
element injection when insertRule appears empty):
baseline (1.0.0-nightly.6240): styles=51 rules=0
patched (this branch): styles=1 rules=50
The leak collapses: instead of 50 <style> fallbacks stacking up, the
single persistent stylesheet receives all 50 insertions and dedup works.
Note on partial coverage of #2459: the original Allbirds reproducer
involves a Vue.js + Yotpo widget that injects <style> elements via
direct document.head.appendChild rather than insertRule. That code
path is unaffected by this change; it appears to be a separate
mechanism (possibly related to Vue's vue-style-loader closure-based
dedup or web-component lifecycle on Lightpanda) and is worth filing
separately. This PR fixes the specific insertRule contract issue
described in #2459 and unblocks the major CSS-in-JS libraries.