From cde8229be5c2b6327d325b3f6afa2fbd5f76a4e4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Apr 2026 09:52:03 +0800 Subject: [PATCH 1/6] Correctly treat a view's offset as a byte offset, not an element offset --- src/browser/js/Local.zig | 46 +++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 170e5c0c..8079821f 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -781,62 +781,60 @@ fn jsValueToTypedArray(comptime T: type, js_val: js.Value) !?[]T { const backing_store_ptr = v8.v8__ArrayBuffer__GetBackingStore(array_buffer orelse return null); 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 => {}, From de167861c6ab9b4788a72eae427b925fd9a996bf Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Apr 2026 10:03:19 +0800 Subject: [PATCH 2/6] handle null buffers --- src/browser/js/Local.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 8079821f..6a1750f8 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -777,6 +777,9 @@ fn jsValueToTypedArray(comptime T: type, js_val: js.Value) !?[]T { byte_len = v8.v8__ArrayBuffer__ByteLength(array_buffer); byte_offset = 0; } + if (byte_len == 0) { + return &[_]T{}; + } const backing_store_ptr = v8.v8__ArrayBuffer__GetBackingStore(array_buffer orelse return null); const backing_store_handle = v8.std__shared_ptr__v8__BackingStore__get(&backing_store_ptr).?; From 28a7e7fe45dfd9ef17cc779c55af640e1ef0e1ae Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Apr 2026 11:21:59 +0800 Subject: [PATCH 3/6] Basic protocol support for websocket. Websockets client can send a Protocol which the server can agree to. This isn't as fancy as it sounds. We just send a specific header on websocket handshake and then read the response header. --- src/browser/webapi/net/WebSocket.zig | 64 +++++++++++++++++++++------- 1 file changed, 48 insertions(+), 16 deletions(-) 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, .{}); From 5c161260fd175ddb1e309df95b3704a5bb6620ee Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Apr 2026 11:35:32 +0800 Subject: [PATCH 4/6] don't break union probing --- src/browser/js/Local.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 6a1750f8..213f7af2 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -777,11 +777,12 @@ fn jsValueToTypedArray(comptime T: type, js_val: js.Value) !?[]T { byte_len = v8.v8__ArrayBuffer__ByteLength(array_buffer); byte_offset = 0; } + + const backing_store_ptr = v8.v8__ArrayBuffer__GetBackingStore(array_buffer orelse return null); if (byte_len == 0) { return &[_]T{}; } - const backing_store_ptr = v8.v8__ArrayBuffer__GetBackingStore(array_buffer orelse return null); 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; From 592dc3e18d191b5361329518b07947416fae494c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Apr 2026 12:30:49 +0800 Subject: [PATCH 5/6] Add EventCounts API --- src/browser/EventManager.zig | 3 + src/browser/js/bridge.zig | 1 + src/browser/tests/performance.html | 138 +++++++++++++++++++++++ src/browser/webapi/EventCounts.zig | 172 +++++++++++++++++++++++++++++ src/browser/webapi/Performance.zig | 8 ++ 5 files changed, 322 insertions(+) create mode 100644 src/browser/webapi/EventCounts.zig 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/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 { From 299104ff1d60d21052705825dc4f006a734a61b9 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 13 Apr 2026 13:06:16 +0800 Subject: [PATCH 6/6] TextDecoder streaming stop When we stop streaming, we need to use any previously streamed data as part of the last "unstreamed" chunk. Or, put differently, when stream is false, that merely stops any subsequent streams, it doesn't discard any previously streamed data. --- src/browser/webapi/encoding/TextDecoder.zig | 37 ++++++++++++--------- 1 file changed, 22 insertions(+), 15 deletions(-) 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(