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, .{});