diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig
index e5ff22d5..97c0a9cc 100644
--- a/src/browser/EventManager.zig
+++ b/src/browser/EventManager.zig
@@ -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 });
}
diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig
index 170e5c0c..213f7af2 100644
--- a/src/browser/js/Local.zig
+++ b/src/browser/js/Local.zig
@@ -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 => {},
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 93c51f78..dc22a996 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -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"),
diff --git a/src/browser/tests/performance.html b/src/browser/tests/performance.html
index d0383d90..069d011c 100644
--- a/src/browser/tests/performance.html
+++ b/src/browser/tests/performance.html
@@ -393,3 +393,141 @@
}
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/EventCounts.zig b/src/browser/webapi/EventCounts.zig
new file mode 100644
index 00000000..31b71628
--- /dev/null
+++ b/src/browser/webapi/EventCounts.zig
@@ -0,0 +1,172 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// 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 .
+
+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);
diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig
index 50c4457a..6880bed0 100644
--- a/src/browser/webapi/Performance.zig
+++ b/src/browser/webapi/Performance.zig
@@ -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 {
diff --git a/src/browser/webapi/encoding/TextDecoder.zig b/src/browser/webapi/encoding/TextDecoder.zig
index 7668e48e..89ef3023 100644
--- a/src/browser/webapi/encoding/TextDecoder.zig
+++ b/src/browser/webapi/encoding/TextDecoder.zig
@@ -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(
diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig
index aef8809a..5f0c09ac 100644
--- a/src/browser/webapi/net/WebSocket.zig
+++ b/src/browser/webapi/net/WebSocket.zig
@@ -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, .{});