Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-04-13 12:39:45 +02:00
8 changed files with 418 additions and 55 deletions

View File

@@ -107,6 +107,9 @@ pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, co
event.acquireRef();
defer _ = event.releaseRef(self.page._session);
// Increment event count for Event Timing API
self.page.window._performance._event_counts.increment(event._type_string.str());
if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
}

View File

@@ -779,64 +779,66 @@ fn jsValueToTypedArray(comptime T: type, js_val: js.Value) !?[]T {
}
const backing_store_ptr = v8.v8__ArrayBuffer__GetBackingStore(array_buffer orelse return null);
if (byte_len == 0) {
return &[_]T{};
}
const backing_store_handle = v8.std__shared_ptr__v8__BackingStore__get(&backing_store_ptr).?;
const data = v8.v8__BackingStore__Data(backing_store_handle);
const base = @as([*]u8, @ptrCast(data)) + byte_offset;
// 2. Validate alignment
if (@intFromPtr(base) % @alignOf(T) != 0) {
return error.InvalidAlignment;
}
const num_elements = byte_len / @sizeOf(T);
switch (T) {
u8 => {
if (force_u8 or js_val.isUint8Array() or js_val.isUint8ClampedArray()) {
if (byte_len == 0) return &[_]u8{};
const arr_ptr = @as([*]u8, @ptrCast(@alignCast(data)));
return arr_ptr[byte_offset .. byte_offset + byte_len];
return base[0..num_elements];
}
},
i8 => {
if (js_val.isInt8Array()) {
if (byte_len == 0) return &[_]i8{};
const arr_ptr = @as([*]i8, @ptrCast(@alignCast(data)));
return arr_ptr[byte_offset .. byte_offset + byte_len];
const ptr = @as([*]i8, @ptrCast(@alignCast(base)));
return ptr[0..num_elements];
}
},
u16 => {
if (js_val.isUint16Array()) {
if (byte_len == 0) return &[_]u16{};
const arr_ptr = @as([*]u16, @ptrCast(@alignCast(data)));
return arr_ptr[byte_offset .. byte_offset + byte_len / 2];
const ptr = @as([*]u16, @ptrCast(@alignCast(base)));
return ptr[0..num_elements];
}
},
i16 => {
if (js_val.isInt16Array()) {
if (byte_len == 0) return &[_]i16{};
const arr_ptr = @as([*]i16, @ptrCast(@alignCast(data)));
return arr_ptr[byte_offset .. byte_offset + byte_len / 2];
const ptr = @as([*]i16, @ptrCast(@alignCast(base)));
return ptr[0..num_elements];
}
},
u32 => {
if (js_val.isUint32Array()) {
if (byte_len == 0) return &[_]u32{};
const arr_ptr = @as([*]u32, @ptrCast(@alignCast(data)));
return arr_ptr[byte_offset .. byte_offset + byte_len / 4];
const ptr = @as([*]u32, @ptrCast(@alignCast(base)));
return ptr[0..num_elements];
}
},
i32 => {
if (js_val.isInt32Array()) {
if (byte_len == 0) return &[_]i32{};
const arr_ptr = @as([*]i32, @ptrCast(@alignCast(data)));
return arr_ptr[byte_offset .. byte_offset + byte_len / 4];
const ptr = @as([*]i32, @ptrCast(@alignCast(base)));
return ptr[0..num_elements];
}
},
u64 => {
if (js_val.isBigUint64Array()) {
if (byte_len == 0) return &[_]u64{};
const arr_ptr = @as([*]u64, @ptrCast(@alignCast(data)));
return arr_ptr[byte_offset .. byte_offset + byte_len / 8];
const ptr = @as([*]u64, @ptrCast(@alignCast(base)));
return ptr[0..num_elements];
}
},
i64 => {
if (js_val.isBigInt64Array()) {
if (byte_len == 0) return &[_]i64{};
const arr_ptr = @as([*]i64, @ptrCast(@alignCast(data)));
return arr_ptr[byte_offset .. byte_offset + byte_len / 8];
const ptr = @as([*]i64, @ptrCast(@alignCast(base)));
return ptr[0..num_elements];
}
},
else => {},

View File

@@ -859,6 +859,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/URL.zig"),
@import("../webapi/Window.zig"),
@import("../webapi/Performance.zig"),
@import("../webapi/EventCounts.zig"),
@import("../webapi/PluginArray.zig"),
@import("../webapi/MutationObserver.zig"),
@import("../webapi/IntersectionObserver.zig"),

View File

@@ -393,3 +393,141 @@
}
}
</script>
<script id=eventCounts_exists>
{
const ec = performance.eventCounts;
testing.expectEqual('object', typeof ec);
testing.expectEqual(false, ec === null);
testing.expectEqual(false, ec === undefined);
}
</script>
<script id=eventCounts_same_object>
{
// performance.eventCounts should return the same object on each access
testing.expectEqual(true, performance.eventCounts === performance.eventCounts);
}
</script>
<script id=eventCounts_size>
{
// EventCounts tracks 36 event types per Event Timing spec
const ec = performance.eventCounts;
testing.expectEqual('number', typeof ec.size);
testing.expectEqual(36, ec.size);
}
</script>
<script id=eventCounts_has_tracked_events>
{
const ec = performance.eventCounts;
// These should be tracked
testing.expectEqual(true, ec.has("click"));
testing.expectEqual(true, ec.has("keydown"));
testing.expectEqual(true, ec.has("keyup"));
testing.expectEqual(true, ec.has("mousedown"));
testing.expectEqual(true, ec.has("mouseup"));
testing.expectEqual(true, ec.has("pointerdown"));
testing.expectEqual(true, ec.has("input"));
testing.expectEqual(true, ec.has("touchstart"));
}
</script>
<script id=eventCounts_has_non_tracked_events>
{
const ec = performance.eventCounts;
// These should NOT be tracked
testing.expectEqual(false, ec.has("load"));
testing.expectEqual(false, ec.has("scroll"));
testing.expectEqual(false, ec.has("resize"));
testing.expectEqual(false, ec.has("DOMContentLoaded"));
testing.expectEqual(false, ec.has("nonexistent"));
}
</script>
<script id=eventCounts_get_returns_number>
{
const ec = performance.eventCounts;
// get() should return a number for tracked events
testing.expectEqual('number', typeof ec.get("click"));
testing.expectEqual('number', typeof ec.get("keydown"));
}
</script>
<script id=eventCounts_get_returns_zero_for_non_tracked>
{
const ec = performance.eventCounts;
// get() should return 0 for non-tracked events (not undefined)
testing.expectEqual(0, ec.get("load"));
testing.expectEqual(0, ec.get("nonexistent"));
}
</script>
<script id=eventCounts_keys>
{
const ec = performance.eventCounts;
const keys = Array.from(ec.keys());
testing.expectEqual(36, keys.length);
testing.expectEqual(true, keys.includes("click"));
testing.expectEqual(true, keys.includes("keydown"));
testing.expectEqual(true, keys.includes("mousedown"));
}
</script>
<script id=eventCounts_values>
{
const ec = performance.eventCounts;
const values = Array.from(ec.values());
testing.expectEqual(36, values.length);
// All values should be numbers
for (const v of values) {
testing.expectEqual('number', typeof v);
}
}
</script>
<script id=eventCounts_entries>
{
const ec = performance.eventCounts;
const entries = Array.from(ec.entries());
testing.expectEqual(36, entries.length);
// Each entry should be [string, number]
for (const e of entries) {
testing.expectEqual(true, Array.isArray(e));
testing.expectEqual(2, e.length);
testing.expectEqual('string', typeof e[0]);
testing.expectEqual('number', typeof e[1]);
}
}
</script>
<script id=eventCounts_forEach>
{
const ec = performance.eventCounts;
let count = 0;
let sawClick = false;
ec.forEach((value, key, obj) => {
count++;
testing.expectEqual('number', typeof value);
testing.expectEqual('string', typeof key);
testing.expectEqual(ec, obj);
if (key === "click") sawClick = true;
});
testing.expectEqual(36, count);
testing.expectEqual(true, sawClick);
}
</script>
<script id=eventCounts_iterator>
{
const ec = performance.eventCounts;
let count = 0;
for (const [key, value] of ec) {
count++;
testing.expectEqual('string', typeof key);
testing.expectEqual('number', typeof value);
}
testing.expectEqual(36, count);
}
</script>

View File

@@ -0,0 +1,172 @@
// 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 log = @import("../../log.zig");
pub fn registerTypes() []const type {
return &.{
EventCounts,
KeyIterator,
ValueIterator,
EntryIterator,
};
}
const EventCounts = @This();
// Event types tracked per the Event Timing spec
// https://w3c.github.io/event-timing/#sec-events-exposed
const tracked_event_types = [_][]const u8{
"auxclick",
"click",
"contextmenu",
"dblclick",
"mousedown",
"mouseenter",
"mouseleave",
"mouseout",
"mouseover",
"mouseup",
"pointerover",
"pointerenter",
"pointerdown",
"pointerup",
"pointercancel",
"pointerout",
"pointerleave",
"gotpointercapture",
"lostpointercapture",
"touchstart",
"touchend",
"touchcancel",
"keydown",
"keypress",
"keyup",
"beforeinput",
"input",
"compositionstart",
"compositionupdate",
"compositionend",
"dragstart",
"dragend",
"dragenter",
"dragleave",
"dragover",
"drop",
};
// Counts stored in a fixed array
_counts: [tracked_event_types.len]u32 = [_]u32{0} ** tracked_event_types.len,
pub fn increment(self: *EventCounts, event_type: []const u8) void {
if (getIndex(event_type)) |idx| {
self._counts[idx] +|= 1;
}
}
pub fn get(self: *const EventCounts, event_type: []const u8) u32 {
if (getIndex(event_type)) |idx| {
return self._counts[idx];
}
return 0;
}
pub fn has(_: *const EventCounts, event_type: []const u8) bool {
return getIndex(event_type) != null;
}
pub fn getSize(_: *const EventCounts) u32 {
return tracked_event_types.len;
}
pub fn keys(self: *EventCounts, page: *Page) !*KeyIterator {
return .init(.{ .event_counts = self }, page);
}
pub fn values(self: *EventCounts, page: *Page) !*ValueIterator {
return .init(.{ .event_counts = self }, page);
}
pub fn entries(self: *EventCounts, page: *Page) !*EntryIterator {
return .init(.{ .event_counts = self }, page);
}
pub fn forEach(self: *EventCounts, cb_: js.Function, js_this_: ?js.Object) !void {
const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_;
for (tracked_event_types, self._counts) |event_type, count| {
var caught: js.TryCatch.Caught = undefined;
cb.tryCall(void, .{ count, event_type, self }, &caught) catch {
log.debug(.js, "forEach callback", .{ .caught = caught, .source = "EventCounts" });
};
}
}
fn getIndex(event_type: []const u8) ?usize {
for (tracked_event_types, 0..) |t, i| {
if (std.mem.eql(u8, t, event_type)) {
return i;
}
}
return null;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(EventCounts);
pub const Meta = struct {
pub const name = "EventCounts";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const get = bridge.function(EventCounts.get, .{});
pub const has = bridge.function(EventCounts.has, .{});
pub const size = bridge.accessor(EventCounts.getSize, null, .{});
pub const keys = bridge.function(EventCounts.keys, .{});
pub const values = bridge.function(EventCounts.values, .{});
pub const entries = bridge.function(EventCounts.entries, .{});
pub const forEach = bridge.function(EventCounts.forEach, .{});
pub const symbol_iterator = bridge.iterator(EventCounts.entries, .{});
};
// Iterator implementation
pub const Iterator = struct {
index: u32 = 0,
event_counts: *EventCounts,
pub const Entry = struct { []const u8, u32 };
pub fn next(self: *Iterator, _: *const Page) ?Iterator.Entry {
const index = self.index;
if (index >= tracked_event_types.len) {
return null;
}
self.index = index + 1;
return .{ tracked_event_types[index], self.event_counts._counts[index] };
}
};
const GenericIterator = @import("collections/iterator.zig").Entry;
pub const KeyIterator = GenericIterator(Iterator, "0");
pub const ValueIterator = GenericIterator(Iterator, "1");
pub const EntryIterator = GenericIterator(Iterator, null);

View File

@@ -2,6 +2,8 @@ const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const datetime = @import("../../datetime.zig");
const EventCounts = @import("EventCounts.zig");
pub fn registerTypes() []const type {
return &.{ Performance, Entry, Mark, Measure, PerformanceTiming, PerformanceNavigation };
}
@@ -14,6 +16,7 @@ _time_origin: u64,
_entries: std.ArrayList(*Entry) = .{},
_timing: PerformanceTiming = .{},
_navigation: PerformanceNavigation = .{},
_event_counts: EventCounts = .{},
/// Get high-resolution timestamp in microseconds, rounded to 5μs increments
/// to match browser behavior (prevents fingerprinting)
@@ -54,6 +57,10 @@ pub fn getNavigation(self: *Performance) *PerformanceNavigation {
return &self._navigation;
}
pub fn getEventCounts(self: *Performance) *EventCounts {
return &self._event_counts;
}
pub fn mark(
self: *Performance,
name: []const u8,
@@ -277,6 +284,7 @@ pub const JsApi = struct {
pub const timeOrigin = bridge.accessor(Performance.getTimeOrigin, null, .{});
pub const timing = bridge.accessor(Performance.getTiming, null, .{});
pub const navigation = bridge.accessor(Performance.getNavigation, null, .{});
pub const eventCounts = bridge.accessor(Performance.getEventCounts, null, .{});
};
pub const Entry = struct {

View File

@@ -115,32 +115,39 @@ pub fn decode(self: *TextDecoder, input_: ?[]const u8, opts_: ?DecodeOpts) ![]co
const opts: DecodeOpts = opts_ orelse .{};
const input = input_ orelse "";
// For non-streaming calls, we don't need a persistent decoder
if (!opts.stream) {
// Reset decoder state if we had one
if (self._decoder) |decoder| {
if (opts.stream) {
// Streaming mode: create decoder if needed, keep it alive
if (self._decoder == null) {
self._decoder = html5ever.encoding_decoder_new(self._encoding_handle);
if (self._decoder == null) {
return error.OutOfMemory;
}
}
return self._decode(input, self._decoder, false);
}
if (self._decoder) |decoder| {
// Non-streaming with existing decoder: flush with is_last=true, then free
defer {
html5ever.encoding_decoder_free(decoder);
self._decoder = null;
}
} else if (self._decoder == null) {
self._decoder = html5ever.encoding_decoder_new(self._encoding_handle);
if (self._decoder == null) {
return error.OutOfMemory;
}
return self._decode(input, decoder, true);
}
return self._decode(input, self._decoder);
// non-streaming, no existing decoder
return self._decode(input, null, true);
}
fn _decode(self: *TextDecoder, input: []const u8, streaming_decoder: ?*anyopaque) ![]const u8 {
if (input.len == 0) {
fn _decode(self: *TextDecoder, input: []const u8, streaming_decoder: ?*anyopaque, is_last: bool) ![]const u8 {
if (input.len == 0 and !is_last) {
return "";
}
// Calculate max output size
// Calculate max output size (add extra for potential buffered bytes when finishing)
const max_out = html5ever.encoding_max_utf8_buffer_length(
self._encoding_handle,
input.len,
if (input.len == 0) 4 else input.len,
);
if (max_out == 0) {
@@ -158,7 +165,7 @@ fn _decode(self: *TextDecoder, input: []const u8, streaming_decoder: ?*anyopaque
input.len,
output.ptr,
output.len,
0, // is_last = false for streaming
@intFromBool(is_last),
)
else
html5ever.encoding_decode(

View File

@@ -54,6 +54,7 @@ _got_upgrade: bool = false,
_conn: ?*http.Connection,
_http_client: *HttpClient,
_req_headers: http.Headers,
// buffered outgoing messages
_send_queue: std.ArrayList(Message) = .empty,
@@ -66,6 +67,9 @@ _recv_buffer: std.ArrayList(u8) = .empty,
_close_code: u16 = 1000,
_close_reason: []const u8 = "",
// negotiated protocol
_protocol: []const u8 = "",
// Event handlers
_on_open: ?js.Function.Temp = null,
_on_message: ?js.Function.Temp = null,
@@ -84,13 +88,21 @@ pub const BinaryType = enum {
arraybuffer,
};
pub fn init(url: []const u8, protocols_: ?[]const u8, page: *Page) !*WebSocket {
if (protocols_) |protocols| {
if (protocols.len > 0) {
log.warn(.not_implemented, "WS protocols", .{ .protocols = protocols });
fn isValidProtocol(protocol: []const u8) bool {
if (protocol.len == 0) return false;
for (protocol) |c| {
// Control characters
if (c <= 31 or c == 127) return false;
// Separators per RFC 2616
switch (c) {
'(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ', '\t' => return false,
else => {},
}
}
return true;
}
pub fn init(url: []const u8, protocols: [][]const u8, page: *Page) !*WebSocket {
{
if (url.len < 6) {
return error.SyntaxError;
@@ -103,6 +115,11 @@ pub fn init(url: []const u8, protocols_: ?[]const u8, page: *Page) !*WebSocket {
if (std.mem.indexOfScalar(u8, url, '#') != null) {
return error.SyntaxError;
}
for (protocols) |protocol| {
if (!isValidProtocol(protocol)) {
return error.SyntaxError;
}
}
}
const arena = try page.getArena(.medium, "WebSocket");
@@ -124,12 +141,21 @@ pub fn init(url: []const u8, protocols_: ?[]const u8, page: *Page) !*WebSocket {
try conn.setWriteCallback(receivedDataCallback);
try conn.setHeaderCallback(receivedHeaderCallback);
var headers = try http_client.newHeaders();
errdefer headers.deinit();
if (protocols.len > 0) {
const header = try std.fmt.allocPrintSentinel(arena, "Sec-WebSocket-Protocol: {s}", .{try std.mem.join(arena, ", ", protocols)}, 0);
try headers.add(header);
try conn.setHeaders(&headers);
}
const self = try page._factory.eventTargetWithAllocator(arena, WebSocket{
._page = page,
._conn = conn,
._arena = arena,
._proto = undefined,
._url = resolved_url,
._req_headers = headers,
._http_client = http_client,
});
conn.transport = .{ .websocket = self };
@@ -206,6 +232,7 @@ pub fn disconnected(self: *WebSocket, err_: ?anyerror) void {
fn cleanup(self: *WebSocket) void {
if (self._conn) |conn| {
self._http_client.removeConn(conn);
self._req_headers.deinit();
self._conn = null;
self.releaseRef(self._page._session);
self._send_queue.clearRetainingCapacity();
@@ -356,6 +383,10 @@ pub fn getBinaryType(self: *const WebSocket) []const u8 {
return @tagName(self._binary_type);
}
pub fn getProtocol(self: *const WebSocket) []const u8 {
return self._protocol;
}
pub fn setBinaryType(self: *WebSocket, value: []const u8) void {
if (std.meta.stringToEnum(BinaryType, value)) |bt| {
self._binary_type = bt;
@@ -653,23 +684,24 @@ fn receivedHeaderCallback(buffer: [*]const u8, header_count: usize, buf_len: usi
return buf_len;
}
if (self._got_upgrade) {
// dont' care about headers once we've gotten the upgrade header
return buf_len;
}
const colon = std.mem.indexOfScalarPos(u8, header, 0, ':') orelse {
// weird, continue...
return buf_len;
};
if (std.ascii.eqlIgnoreCase(header[0..colon], "upgrade") == false) {
return buf_len;
}
const header_name = header[0..colon];
const value = std.mem.trim(u8, header[colon + 1 ..], " \t\r\n");
if (std.ascii.eqlIgnoreCase(value, "websocket")) {
self._got_upgrade = true;
if (std.ascii.eqlIgnoreCase(header_name, "upgrade")) {
if (std.ascii.eqlIgnoreCase(value, "websocket")) {
self._got_upgrade = true;
}
} else if (std.ascii.eqlIgnoreCase(header_name, "sec-websocket-protocol")) {
// TODO, we should validate this against our sent list.
self._protocol = self._arena.dupe(u8, value) catch |err| {
log.err(.websocket, "dupe protocol", .{ .err = err });
return 0;
};
}
return buf_len;
@@ -713,7 +745,7 @@ pub const JsApi = struct {
pub const bufferedAmount = bridge.accessor(WebSocket.getBufferedAmount, null, .{});
pub const binaryType = bridge.accessor(WebSocket.getBinaryType, WebSocket.setBinaryType, .{});
pub const protocol = bridge.property("", .{ .template = false });
pub const protocol = bridge.accessor(WebSocket.getProtocol, null, .{});
pub const extensions = bridge.property("", .{ .template = false });
pub const onopen = bridge.accessor(WebSocket.getOnOpen, WebSocket.setOnOpen, .{});