From 06cc808728d0c33f7f48303439380f262a3ef9f8 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 27 May 2026 16:44:35 +0200 Subject: [PATCH 01/14] first draft for CookieStore API implementation --- src/browser/js/bridge.zig | 4 + src/browser/tests/cookie_store.html | 87 +++++ src/browser/webapi/Event.zig | 2 + src/browser/webapi/EventTarget.zig | 3 + src/browser/webapi/Window.zig | 10 + src/browser/webapi/WorkerGlobalScope.zig | 10 + .../webapi/event/CookieChangeEvent.zig | 90 +++++ src/browser/webapi/storage/CookieStore.zig | 369 ++++++++++++++++++ 8 files changed, 575 insertions(+) create mode 100644 src/browser/tests/cookie_store.html create mode 100644 src/browser/webapi/event/CookieChangeEvent.zig create mode 100644 src/browser/webapi/storage/CookieStore.zig diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 96c5cd65..2f9a073c 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -959,6 +959,8 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/streams/TransformStream.zig"), @import("../webapi/Node.zig"), @import("../webapi/storage/storage.zig"), + @import("../webapi/storage/CookieStore.zig"), + @import("../webapi/event/CookieChangeEvent.zig"), @import("../webapi/URL.zig"), @import("../webapi/Window.zig"), @import("../webapi/Performance.zig"), @@ -1038,6 +1040,8 @@ pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/ImageData.zig"), @import("../webapi/Performance.zig"), @import("../webapi/PerformanceObserver.zig"), + @import("../webapi/storage/CookieStore.zig"), + @import("../webapi/event/CookieChangeEvent.zig"), }); // Master list of ALL JS APIs across all contexts. diff --git a/src/browser/tests/cookie_store.html b/src/browser/tests/cookie_store.html new file mode 100644 index 00000000..ca631af1 --- /dev/null +++ b/src/browser/tests/cookie_store.html @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index a59c2210..1ff84415 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -80,6 +80,7 @@ pub const Type = union(enum) { submit_event: *@import("event/SubmitEvent.zig"), form_data_event: *@import("event/FormDataEvent.zig"), close_event: *@import("event/CloseEvent.zig"), + cookie_change_event: *@import("event/CookieChangeEvent.zig"), }; pub const Options = struct { @@ -170,6 +171,7 @@ pub fn is(self: *Event, comptime T: type) ?*T { .submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null, .form_data_event => |e| return if (T == @import("event/FormDataEvent.zig")) e else null, .close_event => |e| return if (T == @import("event/CloseEvent.zig")) e else null, + .cookie_change_event => |e| return if (T == @import("event/CookieChangeEvent.zig")) e else null, .ui_event => |e| { if (T == @import("event/UIEvent.zig")) { return e; diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 01505d5e..845537cd 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -49,6 +49,7 @@ pub const Type = union(enum) { file_reader: *@import("FileReader.zig"), font_face_set: *@import("css/FontFaceSet.zig"), websocket: *@import("net/WebSocket.zig"), + cookie_store: *@import("storage/CookieStore.zig"), }; pub fn init(page: *Page) !*EventTarget { @@ -159,6 +160,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { .file_reader => writer.writeAll(""), .font_face_set => writer.writeAll(""), .websocket => writer.writeAll(""), + .cookie_store => writer.writeAll(""), }; } @@ -181,6 +183,7 @@ pub fn toString(self: *EventTarget) []const u8 { .file_reader => return "[object FileReader]", .font_face_set => return "[object FontFaceSet]", .websocket => return "[object WebSocket]", + .cookie_store => return "[object CookieStore]", }; } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 6e8be504..802d6f0f 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -41,6 +41,7 @@ const ErrorEvent = @import("event/ErrorEvent.zig"); const MessageEvent = @import("event/MessageEvent.zig"); const MediaQueryList = @import("css/MediaQueryList.zig"); const storage = @import("storage/storage.zig"); +const CookieStore = @import("storage/CookieStore.zig"); const Element = @import("Element.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); const CustomElementRegistry = @import("CustomElementRegistry.zig"); @@ -71,6 +72,7 @@ _screen: *Screen, _visual_viewport: *VisualViewport, _performance: Performance, _storage_bucket: storage.Bucket = .{}, +_cookie_store: ?*CookieStore = null, _on_load: ?js.Function.Global = null, _on_pageshow: ?js.Function.Global = null, _on_popstate: ?js.Function.Global = null, @@ -204,6 +206,13 @@ pub fn getSessionStorage(self: *Window) *storage.Lookup { return &self._storage_bucket.session; } +pub fn getCookieStore(self: *Window, frame: *Frame) !*CookieStore { + if (self._cookie_store) |cs| return cs; + const cs = try frame._factory.eventTarget(CookieStore{ ._proto = undefined }); + self._cookie_store = cs; + return cs; +} + pub fn getOrigin(self: *const Window) []const u8 { return self._frame.origin orelse "null"; } @@ -873,6 +882,7 @@ pub const JsApi = struct { pub const performance = bridge.accessor(Window.getPerformance, null, .{}); pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{}); pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{}); + pub const cookieStore = bridge.accessor(Window.getCookieStore, null, .{}); pub const origin = bridge.accessor(Window.getOrigin, null, .{}); pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{ .deletable = false }); pub const history = bridge.accessor(Window.getHistory, null, .{}); diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index ee36864d..3bd7d8b4 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -44,6 +44,7 @@ const WorkerLocation = @import("WorkerLocation.zig"); const MessageEvent = @import("event/MessageEvent.zig"); const ErrorEvent = @import("event/ErrorEvent.zig"); const Fetch = @import("net/Fetch.zig"); +const CookieStore = @import("storage/CookieStore.zig"); const builtin = @import("builtin"); const IS_DEBUG = builtin.mode == .Debug; @@ -102,6 +103,7 @@ _on_rejection_handled: ?JS.Function.Global = null, _on_unhandled_rejection: ?JS.Function.Global = null, _on_message: ?JS.Function.Global = null, _on_messageerror: ?JS.Function.Global = null, +_cookie_store: ?*CookieStore = null, _location: WorkerLocation, @@ -257,6 +259,13 @@ pub fn getLocation(self: *WorkerGlobalScope) *WorkerLocation { return &self._location; } +pub fn getCookieStore(self: *WorkerGlobalScope) !*CookieStore { + if (self._cookie_store) |cs| return cs; + const cs = try self._factory.eventTargetWithAllocator(self.arena, CookieStore{ ._proto = undefined }); + self._cookie_store = cs; + return cs; +} + pub fn getOnError(self: *const WorkerGlobalScope) ?JS.Function.Global { return self._on_error; } @@ -643,6 +652,7 @@ pub const JsApi = struct { } }.wrap, null, .{}); pub const location = bridge.accessor(WorkerGlobalScope.getLocation, null, .{}); + pub const cookieStore = bridge.accessor(WorkerGlobalScope.getCookieStore, null, .{}); pub const onerror = bridge.accessor(WorkerGlobalScope.getOnError, WorkerGlobalScope.setOnError, .{}); pub const onrejectionhandled = bridge.accessor(WorkerGlobalScope.getOnRejectionHandled, WorkerGlobalScope.setOnRejectionHandled, .{}); diff --git a/src/browser/webapi/event/CookieChangeEvent.zig b/src/browser/webapi/event/CookieChangeEvent.zig new file mode 100644 index 00000000..19fc8b1a --- /dev/null +++ b/src/browser/webapi/event/CookieChangeEvent.zig @@ -0,0 +1,90 @@ +// 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 lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); + +const Event = @import("../Event.zig"); +const CookieStore = @import("../storage/CookieStore.zig"); + +const String = lp.String; +const Allocator = std.mem.Allocator; + +// https://developer.mozilla.org/en-US/docs/Web/API/CookieChangeEvent +const CookieChangeEvent = @This(); + +_proto: *Event, +_changed: []*CookieStore.CookieListItem, +_deleted: []*CookieStore.CookieListItem, + +const CookieChangeEventOptions = struct { + changed: ?[]*CookieStore.CookieListItem = null, + deleted: ?[]*CookieStore.CookieListItem = null, +}; + +const Options = Event.inheritOptions(CookieChangeEvent, CookieChangeEventOptions); + +pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*CookieChangeEvent { + const arena = try frame.getArena(.tiny, "CookieChangeEvent"); + errdefer frame.releaseArena(arena); + const type_string = try String.init(arena, typ, .{}); + + const opts = _opts orelse Options{}; + + const event = try frame._factory.event( + arena, + type_string, + CookieChangeEvent{ + ._proto = undefined, + ._changed = opts.changed orelse &.{}, + ._deleted = opts.deleted orelse &.{}, + }, + ); + + Event.populatePrototypes(event, opts, false); + return event; +} + +pub fn asEvent(self: *CookieChangeEvent) *Event { + return self._proto; +} + +pub fn getChanged(self: *const CookieChangeEvent) []*CookieStore.CookieListItem { + return self._changed; +} + +pub fn getDeleted(self: *const CookieChangeEvent) []*CookieStore.CookieListItem { + return self._deleted; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(CookieChangeEvent); + + pub const Meta = struct { + pub const name = "CookieChangeEvent"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(CookieChangeEvent.init, .{}); + pub const changed = bridge.accessor(CookieChangeEvent.getChanged, null, .{}); + pub const deleted = bridge.accessor(CookieChangeEvent.getDeleted, null, .{}); +}; diff --git a/src/browser/webapi/storage/CookieStore.zig b/src/browser/webapi/storage/CookieStore.zig new file mode 100644 index 00000000..45b8fcbd --- /dev/null +++ b/src/browser/webapi/storage/CookieStore.zig @@ -0,0 +1,369 @@ +// 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 lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const URL = @import("../../URL.zig"); +const Session = @import("../../Session.zig"); + +const Cookie = @import("Cookie.zig"); +const EventTarget = @import("../EventTarget.zig"); + +const log = lp.log; +const Allocator = std.mem.Allocator; +const Execution = js.Execution; + +pub fn registerTypes() []const type { + return &.{ CookieStore, CookieListItem }; +} + +// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore +const CookieStore = @This(); + +_proto: *EventTarget, +_on_change: ?js.Function.Global = null, + +pub fn asEventTarget(self: *CookieStore) *EventTarget { + return self._proto; +} + +pub fn getOnChange(self: *const CookieStore) ?js.Function.Global { + return self._on_change; +} + +pub fn setOnChange(self: *CookieStore, setter: ?FunctionSetter) void { + const s = setter orelse { + self._on_change = null; + return; + }; + self._on_change = switch (s) { + .func => |f| f, + .anything => null, + }; +} + +const FunctionSetter = union(enum) { + func: js.Function.Global, + anything: js.Value, +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore/get +const GetOptions = struct { + name: ?[]const u8 = null, + url: ?[]const u8 = null, +}; + +const GetInput = union(enum) { + name: []const u8, + options: GetOptions, +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore/set +const CookieInit = struct { + name: []const u8, + value: []const u8, + expires: ?f64 = null, + domain: ?[]const u8 = null, + path: []const u8 = "/", + sameSite: SameSite = .strict, + partitioned: bool = false, +}; + +const SetInput = union(enum) { + name: []const u8, + options: CookieInit, +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore/delete +const DeleteOptions = struct { + name: []const u8, + domain: ?[]const u8 = null, + path: []const u8 = "/", + partitioned: bool = false, +}; + +const DeleteInput = union(enum) { + name: []const u8, + options: DeleteOptions, +}; + +const SameSite = enum { + strict, + lax, + none, + pub const js_enum_from_string = true; +}; + +pub fn get(_: *CookieStore, input: GetInput, exec: *const Execution) !js.Promise { + const local = exec.context.local.?; + + const name: ?[]const u8 = switch (input) { + .name => |n| n, + .options => |o| o.name, + }; + + const items = matchCookies(exec, name, true) catch |err| { + return local.rejectPromise(.{ .type_error = @errorName(err) }); + }; + + if (items.len == 0) { + return local.resolvePromise(@as(?*CookieListItem, null)); + } + return local.resolvePromise(items[0]); +} + +pub fn getAll(_: *CookieStore, input: ?GetInput, exec: *const Execution) !js.Promise { + const local = exec.context.local.?; + + const name: ?[]const u8 = if (input) |inp| switch (inp) { + .name => |n| n, + .options => |o| o.name, + } else null; + + const items = matchCookies(exec, name, false) catch |err| { + return local.rejectPromise(.{ .type_error = @errorName(err) }); + }; + return local.resolvePromise(items); +} + +pub fn set(_: *CookieStore, input: SetInput, value: ?[]const u8, exec: *const Execution) !js.Promise { + const local = exec.context.local.?; + + const init: CookieInit = switch (input) { + .options => |o| o, + .name => |n| .{ + .name = n, + .value = value orelse return local.rejectPromise(.{ .type_error = "value is required" }), + }, + }; + + storeCookie(exec, init) catch |err| { + return local.rejectPromise(.{ .type_error = @errorName(err) }); + }; + + return local.resolvePromise({}); +} + +pub fn delete(_: *CookieStore, input: DeleteInput, exec: *const Execution) !js.Promise { + const local = exec.context.local.?; + + const opts: DeleteOptions = switch (input) { + .options => |o| o, + .name => |n| .{ .name = n }, + }; + + // Deletion per spec is an expired set: write a cookie with the same + // name/path/domain but with Expires in the past, and the Jar will drop + // any existing match (or no-op if none). + storeCookie(exec, .{ + .name = opts.name, + .value = "", + .expires = 0, + .domain = opts.domain, + .path = opts.path, + .sameSite = .strict, + .partitioned = opts.partitioned, + }) catch |err| { + return local.rejectPromise(.{ .type_error = @errorName(err) }); + }; + + return local.resolvePromise({}); +} + +fn matchCookies( + exec: *const Execution, + name: ?[]const u8, + first_only: bool, +) ![]*CookieListItem { + const session = switch (exec.context.global) { + inline else => |g| g._session, + }; + const url = exec.url.*; + + const target = Cookie.PreparedUri{ + .host = URL.getHostname(url), + .path = URL.getPathname(url), + .secure = URL.isHTTPS(url), + }; + if (target.host.len == 0) return error.SecurityError; + + session.cookie_jar.removeExpired(null); + + var items: std.ArrayList(*CookieListItem) = .empty; + for (session.cookie_jar.cookies.items) |*cookie| { + // CookieStore exposes only cookies that script would see for the + // current document. HttpOnly cookies stay hidden. + if (!cookie.appliesTo(&target, true, true, false)) continue; + if (name) |n| { + if (!std.mem.eql(u8, cookie.name, n)) continue; + } + + const item = try exec.call_arena.create(CookieListItem); + item.* = .{ + .name = cookie.name, + .value = cookie.value, + .domain = if (cookie.domain.len > 0 and cookie.domain[0] == '.') cookie.domain[1..] else null, + .path = cookie.path, + .expires = if (cookie.expires) |e| e * 1000.0 else null, + .secure = cookie.secure, + .sameSite = switch (cookie.same_site) { + .strict => .strict, + .lax => .lax, + .none => .none, + }, + .partitioned = false, + }; + try items.append(exec.call_arena, item); + if (first_only) break; + } + + return items.items; +} + +fn storeCookie(exec: *const Execution, init: CookieInit) !void { + const session = switch (exec.context.global) { + inline else => |g| g._session, + }; + const url = exec.url.*; + + // Reuse the Set-Cookie parser by serialising the dict back into a + // header-shaped string. This keeps domain/path validation, public + // suffix list checks and prefix rules in one place. + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(exec.call_arena); + const w = buf.writer(exec.call_arena); + + try w.writeAll(init.name); + try w.writeByte('='); + try w.writeAll(init.value); + + try w.writeAll("; Path="); + try w.writeAll(if (init.path.len > 0) init.path else "/"); + + if (init.domain) |d| { + if (d.len > 0) { + try w.writeAll("; Domain="); + try w.writeAll(d); + } + } + + if (init.expires) |ms| { + // CookieStore expires is a unix timestamp in milliseconds. The Jar + // already tracks expiry in seconds — convert via Max-Age so we don't + // have to format an HTTP-date. + const now_ms = std.time.timestamp() * 1000; + const delta_seconds = @divTrunc(@as(i64, @intFromFloat(ms)) - now_ms, 1000); + try w.print("; Max-Age={d}", .{delta_seconds}); + } + + switch (init.sameSite) { + .strict => try w.writeAll("; SameSite=Strict"), + .lax => try w.writeAll("; SameSite=Lax"), + .none => try w.writeAll("; SameSite=None; Secure"), + } + + // Per spec, CookieStore-written cookies that target an HTTPS page are + // Secure. We add the flag for https URLs (idempotent when SameSite=None + // already added it above). + if (URL.isHTTPS(url) and init.sameSite != .none) { + try w.writeAll("; Secure"); + } + + const cookie = try Cookie.parse(session.cookie_jar.allocator, url, buf.items); + errdefer cookie.deinit(); + // CookieStore is a script API, so is_http = false. + try session.cookie_jar.add(cookie, std.time.timestamp(), false); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(CookieStore); + + pub const Meta = struct { + pub const name = "CookieStore"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const get = bridge.function(CookieStore.get, .{}); + pub const getAll = bridge.function(CookieStore.getAll, .{}); + pub const set = bridge.function(CookieStore.set, .{}); + pub const delete = bridge.function(CookieStore.delete, .{}); + pub const onchange = bridge.accessor(CookieStore.getOnChange, CookieStore.setOnChange, .{}); +}; + +// CookieListItem: per CookieStore.get / getAll return shape, documented inline on +// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore +pub const CookieListItem = struct { + name: []const u8, + value: []const u8, + domain: ?[]const u8, + path: []const u8, + expires: ?f64, + secure: bool, + sameSite: SameSite, + partitioned: bool, + + fn getName(self: *const CookieListItem) []const u8 { + return self.name; + } + fn getValue(self: *const CookieListItem) []const u8 { + return self.value; + } + fn getDomain(self: *const CookieListItem) ?[]const u8 { + return self.domain; + } + fn getPath(self: *const CookieListItem) []const u8 { + return self.path; + } + fn getExpires(self: *const CookieListItem) ?f64 { + return self.expires; + } + fn getSecure(self: *const CookieListItem) bool { + return self.secure; + } + fn getSameSite(self: *const CookieListItem) []const u8 { + return @tagName(self.sameSite); + } + fn getPartitioned(self: *const CookieListItem) bool { + return self.partitioned; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(CookieListItem); + pub const Meta = struct { + pub const name = "CookieListItem"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + pub const name = bridge.accessor(CookieListItem.getName, null, .{}); + pub const value = bridge.accessor(CookieListItem.getValue, null, .{}); + pub const domain = bridge.accessor(CookieListItem.getDomain, null, .{}); + pub const path = bridge.accessor(CookieListItem.getPath, null, .{}); + pub const expires = bridge.accessor(CookieListItem.getExpires, null, .{}); + pub const secure = bridge.accessor(CookieListItem.getSecure, null, .{}); + pub const sameSite = bridge.accessor(CookieListItem.getSameSite, null, .{}); + pub const partitioned = bridge.accessor(CookieListItem.getPartitioned, null, .{}); + }; +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: CookieStore" { + try testing.htmlRunner("cookie_store.html", .{}); +} From 9c74fed3097c7c5319232b737ee1412c87274199 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Wed, 27 May 2026 17:58:04 +0200 Subject: [PATCH 02/14] Implement CookieChangeEvent with CookieStore --- src/Notification.zig | 20 +++ src/browser/Frame.zig | 5 + src/browser/Session.zig | 2 +- src/browser/tests/cookie_store.html | 67 +++++++++ src/browser/webapi/Window.zig | 1 + .../webapi/event/CookieChangeEvent.zig | 49 +++++++ src/browser/webapi/storage/Cookie.zig | 39 ++++- src/browser/webapi/storage/CookieStore.zig | 136 +++++++++++++++++- 8 files changed, 309 insertions(+), 10 deletions(-) diff --git a/src/Notification.zig b/src/Notification.zig index ccfd886f..1dff474f 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -56,6 +56,7 @@ const Allocator = std.mem.Allocator; // proper isolation between different CDP clients while allowing a single client // to receive events from all its tabs. const ModelContextTool = @import("browser/webapi/ModelContext.zig").Tool; +const Cookie = @import("browser/webapi/storage/Cookie.zig"); const Notification = @This(); // Every event type (which are hard-coded), has a list of Listeners. @@ -93,6 +94,7 @@ const EventListeners = struct { runtime_console_message: List = .{}, model_context_tool_added: List = .{}, model_context_tool_removed: List = .{}, + cookie_changed: List = .{}, }; const Events = union(enum) { @@ -118,6 +120,7 @@ const Events = union(enum) { runtime_console_message: *const ConsoleMessage, model_context_tool_added: *const ModelContextToolEvent, model_context_tool_removed: *const ModelContextToolEvent, + cookie_changed: *const CookieChanged, }; const EventType = std.meta.FieldEnum(Events); @@ -236,6 +239,23 @@ pub const ModelContextToolEvent = struct { tool: *const ModelContextTool, }; +// Fired by Cookie.Jar after a mutation. Pointers and slices borrow from the +// dispatcher's stack — listeners must copy whatever they need to retain. +pub const CookieChanged = struct { + kind: Kind, + name: []const u8, + value: []const u8, + // Cookie domain in jar-canonical form (leading "." for explicit Domain + // attribute; bare host otherwise). + domain: []const u8, + path: []const u8, + secure: bool, + http_only: bool, + same_site: Cookie.SameSite, + + pub const Kind = enum { changed, deleted }; +}; + pub const DialogResponse = struct { accept: bool = false, // Set when the CDP client sent a `promptText` with `accept: true`. Memory diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index f875d2e6..afc878c5 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -379,6 +379,11 @@ pub fn deinit(self: *Frame) void { self._parse_state.deinit(self); + // Unregister CookieStore from session notifications before the JS + // context (and thus the scheduler) is destroyed, otherwise a late + // mutation could schedule a callback that never runs. + if (self.window._cookie_store) |cs| cs.detach(); + const page = self._page; if (self._queued_navigation) |qn| { diff --git a/src/browser/Session.zig b/src/browser/Session.zig index ce8c2470..f6f85347 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -112,7 +112,7 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi .storage_shed = .{}, .browser = browser, .notification = notification, - .cookie_jar = storage.Cookie.Jar.init(allocator), + .cookie_jar = storage.Cookie.Jar.init(allocator, notification), // CLI defaults; LP.configureLoading can flip these per-session. .subframe_loading_enabled = !browser.app.config.disableSubframes(), .worker_loading_enabled = !browser.app.config.disableWorkers(), diff --git a/src/browser/tests/cookie_store.html b/src/browser/tests/cookie_store.html index ca631af1..647e8831 100644 --- a/src/browser/tests/cookie_store.html +++ b/src/browser/tests/cookie_store.html @@ -85,3 +85,70 @@ testing.expectEqual(0, ev.changed.length); testing.expectEqual(0, ev.deleted.length); + + + + + + diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 802d6f0f..f06c1110 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -209,6 +209,7 @@ pub fn getSessionStorage(self: *Window) *storage.Lookup { pub fn getCookieStore(self: *Window, frame: *Frame) !*CookieStore { if (self._cookie_store) |cs| return cs; const cs = try frame._factory.eventTarget(CookieStore{ ._proto = undefined }); + try cs.attachToFrame(frame); self._cookie_store = cs; return cs; } diff --git a/src/browser/webapi/event/CookieChangeEvent.zig b/src/browser/webapi/event/CookieChangeEvent.zig index 19fc8b1a..54a50b15 100644 --- a/src/browser/webapi/event/CookieChangeEvent.zig +++ b/src/browser/webapi/event/CookieChangeEvent.zig @@ -21,8 +21,10 @@ const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const Frame = @import("../../Frame.zig"); +const Notification = @import("../../../Notification.zig"); const Event = @import("../Event.zig"); +const Cookie = @import("../storage/Cookie.zig"); const CookieStore = @import("../storage/CookieStore.zig"); const String = lp.String; @@ -63,6 +65,53 @@ pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*CookieChangeEvent return event; } +/// Internal constructor used by the change-notification dispatcher to +/// build a trusted "change" event from a single-cookie mutation snapshot. +/// The CookieListItem is materialised on the event's own arena. +pub fn initSingle( + kind: Notification.CookieChanged.Kind, + snapshot: Notification.CookieChanged, + frame: *Frame, +) !*CookieChangeEvent { + const arena = try frame.getArena(.tiny, "CookieChangeEvent"); + errdefer frame.releaseArena(arena); + const type_string = try String.init(arena, "change", .{}); + + const item = try arena.create(CookieStore.CookieListItem); + item.* = .{ + .name = try arena.dupe(u8, snapshot.name), + .value = try arena.dupe(u8, snapshot.value), + .domain = if (snapshot.domain.len > 0 and snapshot.domain[0] == '.') + try arena.dupe(u8, snapshot.domain[1..]) + else + null, + .path = try arena.dupe(u8, snapshot.path), + .expires = null, + .secure = snapshot.secure, + .sameSite = switch (snapshot.same_site) { + .strict => .strict, + .lax => .lax, + .none => .none, + }, + .partitioned = false, + }; + + const items = try arena.dupe(*CookieStore.CookieListItem, &.{item}); + + const event = try frame._factory.event( + arena, + type_string, + CookieChangeEvent{ + ._proto = undefined, + ._changed = if (kind == .changed) items else &.{}, + ._deleted = if (kind == .deleted) items else &.{}, + }, + ); + + Event.populatePrototypes(event, Options{}, true); + return event; +} + pub fn asEvent(self: *CookieChangeEvent) *Event { return self._proto; } diff --git a/src/browser/webapi/storage/Cookie.zig b/src/browser/webapi/storage/Cookie.zig index 6eef2b57..6fb94763 100644 --- a/src/browser/webapi/storage/Cookie.zig +++ b/src/browser/webapi/storage/Cookie.zig @@ -21,6 +21,7 @@ const lp = @import("lightpanda"); const URL = @import("../../URL.zig"); const DateTime = @import("../../../datetime.zig").DateTime; +const Notification = @import("../../../Notification.zig"); const public_suffix_list = @import("../../../data/public_suffix_list.zig").lookup; const log = lp.log; @@ -449,11 +450,16 @@ pub fn appliesTo(self: *const Cookie, url: *const PreparedUri, same_site: bool, pub const Jar = struct { allocator: Allocator, cookies: std.ArrayList(Cookie), + // Optional notifier for cookie change events. When set, `add` dispatches + // `cookie_changed` after a real mutation. Session populates this; the + // standalone test construction leaves it null. + notification: ?*Notification = null, - pub fn init(allocator: Allocator) Jar { + pub fn init(allocator: Allocator, notification: ?*Notification) Jar { return .{ .cookies = .{}, .allocator = allocator, + .notification = notification, }; } @@ -506,17 +512,38 @@ pub const Jar = struct { c.deinit(); if (is_expired) { _ = self.cookies.swapRemove(i); + self.dispatchChange(.deleted, &cookie); } else { self.cookies.items[i] = cookie; + self.dispatchChange(.changed, &self.cookies.items[i]); } return; } if (!is_expired) { try self.cookies.append(self.allocator, cookie); + self.dispatchChange(.changed, &self.cookies.items[self.cookies.items.len - 1]); } } + fn dispatchChange( + self: *Jar, + kind: Notification.CookieChanged.Kind, + cookie: *const Cookie, + ) void { + const notification = self.notification orelse return; + notification.dispatch(.cookie_changed, &.{ + .kind = kind, + .name = cookie.name, + .value = cookie.value, + .domain = cookie.domain, + .path = cookie.path, + .secure = cookie.secure, + .http_only = cookie.http_only, + .same_site = cookie.same_site, + }); + } + pub fn removeExpired(self: *Jar, request_time: ?i64) void { if (self.cookies.items.len == 0) return; const time = request_time orelse std.time.timestamp(); @@ -691,7 +718,7 @@ test "Jar: add" { const now = std.time.timestamp(); - var jar = Jar.init(testing.allocator); + var jar = Jar.init(testing.allocator, null); defer jar.deinit(); try expectCookies(&.{}, jar); @@ -723,7 +750,7 @@ test "Jar: add" { test "Jar: non-HTTP add must not replace or duplicate an HttpOnly cookie" { const now = std.time.timestamp(); - var jar = Jar.init(testing.allocator); + var jar = Jar.init(testing.allocator, null); defer jar.deinit(); try jar.add(try Cookie.parse(testing.allocator, test_url, "session=REAL;Path=/;HttpOnly"), now, true); @@ -740,7 +767,7 @@ test "Jar: non-HTTP add must not replace or duplicate an HttpOnly cookie" { } test "Jar: add limit" { - var jar = Jar.init(testing.allocator); + var jar = Jar.init(testing.allocator, null); defer jar.deinit(); const now = std.time.timestamp(); @@ -802,7 +829,7 @@ test "Jar: forRequest" { const now = std.time.timestamp(); - var jar = Jar.init(testing.allocator); + var jar = Jar.init(testing.allocator, null); defer jar.deinit(); const url2 = "http://test.lightpanda.io/"; @@ -948,7 +975,7 @@ test "Jar: forRequest SameSite=Strict on cross-site navigation" { } }.expect; - var jar = Jar.init(testing.allocator); + var jar = Jar.init(testing.allocator, null); defer jar.deinit(); const victim_url: [:0]const u8 = "http://victim.example/"; diff --git a/src/browser/webapi/storage/CookieStore.zig b/src/browser/webapi/storage/CookieStore.zig index 45b8fcbd..413f8d4f 100644 --- a/src/browser/webapi/storage/CookieStore.zig +++ b/src/browser/webapi/storage/CookieStore.zig @@ -17,16 +17,16 @@ // along with this program. If not, see . const std = @import("std"); -const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const URL = @import("../../URL.zig"); -const Session = @import("../../Session.zig"); +const Frame = @import("../../Frame.zig"); +const Notification = @import("../../../Notification.zig"); const Cookie = @import("Cookie.zig"); const EventTarget = @import("../EventTarget.zig"); +const CookieChangeEvent = @import("../event/CookieChangeEvent.zig"); -const log = lp.log; const Allocator = std.mem.Allocator; const Execution = js.Execution; @@ -39,11 +39,141 @@ const CookieStore = @This(); _proto: *EventTarget, _on_change: ?js.Function.Global = null, +// Owning Frame, used to filter incoming change notifications by document +// scope and to schedule async event dispatch. Null when the store has no +// frame context (e.g. WorkerGlobalScope), in which case change events are +// not delivered. +_frame: ?*Frame = null, pub fn asEventTarget(self: *CookieStore) *EventTarget { return self._proto; } +/// Registers this CookieStore as a listener for jar-change notifications on +/// the given frame's session. Must be called once after construction by the +/// owning Window. Idempotent on re-call (replaces the frame). +pub fn attachToFrame(self: *CookieStore, frame: *Frame) !void { + if (self._frame != null) return; + self._frame = frame; + try frame._session.notification.register(.cookie_changed, self, onCookieChanged); +} + +/// Removes this CookieStore from the notification list. Called from +/// Frame.deinit before tearing down the JS context. +pub fn detach(self: *CookieStore) void { + const frame = self._frame orelse return; + frame._session.notification.unregisterAll(self); + self._frame = null; +} + +fn onCookieChanged(ctx: *anyopaque, data: *const Notification.CookieChanged) !void { + const self: *CookieStore = @ptrCast(@alignCast(ctx)); + const frame = self._frame orelse return; + + // CookieStore exposes only cookies that script would see for the + // current document — same filter as `match` (HttpOnly hidden, + // same-site treated as first-party against the document URL). + const doc_url = frame.url; + const target = Cookie.PreparedUri{ + .host = URL.getHostname(doc_url), + .path = URL.getPathname(doc_url), + .secure = URL.isHTTPS(doc_url), + }; + if (target.host.len == 0) return; + + const probe = Cookie{ + .arena = undefined, + .name = data.name, + .value = data.value, + .domain = data.domain, + .path = data.path, + .expires = null, + .secure = data.secure, + .http_only = data.http_only, + .same_site = data.same_site, + }; + if (!probe.appliesTo(&target, true, true, false)) return; + + // Per spec, `change` is dispatched as a queued task — never synchronously + // from the mutation site. We snapshot the notification fields onto a + // small page arena that the scheduled callback releases after dispatch. + const arena = try frame.getArena(.tiny, "CookieStore.change"); + errdefer frame.releaseArena(arena); + + const cb = try arena.create(ChangeCallback); + cb.* = .{ + .cookie_store = self, + .arena = arena, + .kind = data.kind, + .name = try arena.dupe(u8, data.name), + .value = try arena.dupe(u8, data.value), + .domain = try arena.dupe(u8, data.domain), + .path = try arena.dupe(u8, data.path), + .secure = data.secure, + .same_site = data.same_site, + }; + + try frame.js.scheduler.add(cb, ChangeCallback.run, 0, .{ + .name = "CookieStore.change", + .low_priority = false, + .finalizer = ChangeCallback.cancelled, + }); +} + +const ChangeCallback = struct { + cookie_store: *CookieStore, + arena: Allocator, + kind: Notification.CookieChanged.Kind, + name: []const u8, + value: []const u8, + domain: []const u8, + path: []const u8, + secure: bool, + same_site: Cookie.SameSite, + + fn cancelled(ctx: *anyopaque) void { + const self: *ChangeCallback = @ptrCast(@alignCast(ctx)); + self.releaseArena(); + } + + fn releaseArena(self: *ChangeCallback) void { + if (self.cookie_store._frame) |frame| { + frame.releaseArena(self.arena); + } + } + + fn run(ctx: *anyopaque) !?u32 { + const self: *ChangeCallback = @ptrCast(@alignCast(ctx)); + defer self.releaseArena(); + + const cs = self.cookie_store; + const frame = cs._frame orelse return null; + const target = cs.asEventTarget(); + + // Skip event construction when nobody is listening. + if (!frame._event_manager.hasDirectListeners(target, "change", cs._on_change)) { + return null; + } + + const event = try CookieChangeEvent.initSingle(self.kind, .{ + .kind = self.kind, + .name = self.name, + .value = self.value, + .domain = self.domain, + .path = self.path, + .secure = self.secure, + .http_only = false, + .same_site = self.same_site, + }, frame); + + try frame._event_manager.dispatchDirect(target, event.asEvent(), cs._on_change, .{ + .context = "CookieStore.change", + }); + + return null; + } +}; + pub fn getOnChange(self: *const CookieStore) ?js.Function.Global { return self._on_change; } From 3029f8eae255970f5591e27f70a67962c35662ce Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 28 May 2026 09:22:13 +0200 Subject: [PATCH 03/14] adjust CookieStore.attachToFrame comment --- src/browser/webapi/storage/CookieStore.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/storage/CookieStore.zig b/src/browser/webapi/storage/CookieStore.zig index 413f8d4f..399e2fc7 100644 --- a/src/browser/webapi/storage/CookieStore.zig +++ b/src/browser/webapi/storage/CookieStore.zig @@ -51,7 +51,7 @@ pub fn asEventTarget(self: *CookieStore) *EventTarget { /// Registers this CookieStore as a listener for jar-change notifications on /// the given frame's session. Must be called once after construction by the -/// owning Window. Idempotent on re-call (replaces the frame). +/// owning Window. Idempotent on re-call. pub fn attachToFrame(self: *CookieStore, frame: *Frame) !void { if (self._frame != null) return; self._frame = frame; From ad0e0445a8f18d6662a712a6f93b0bb204493395 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 28 May 2026 09:50:35 +0200 Subject: [PATCH 04/14] avoid extra allocation for 1 value slice in CookieChangeEvent --- src/browser/webapi/event/CookieChangeEvent.zig | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/browser/webapi/event/CookieChangeEvent.zig b/src/browser/webapi/event/CookieChangeEvent.zig index 54a50b15..f46d3044 100644 --- a/src/browser/webapi/event/CookieChangeEvent.zig +++ b/src/browser/webapi/event/CookieChangeEvent.zig @@ -34,12 +34,12 @@ const Allocator = std.mem.Allocator; const CookieChangeEvent = @This(); _proto: *Event, -_changed: []*CookieStore.CookieListItem, -_deleted: []*CookieStore.CookieListItem, +_changed: []CookieStore.CookieListItem, +_deleted: []CookieStore.CookieListItem, const CookieChangeEventOptions = struct { - changed: ?[]*CookieStore.CookieListItem = null, - deleted: ?[]*CookieStore.CookieListItem = null, + changed: ?[]CookieStore.CookieListItem = null, + deleted: ?[]CookieStore.CookieListItem = null, }; const Options = Event.inheritOptions(CookieChangeEvent, CookieChangeEventOptions); @@ -96,15 +96,13 @@ pub fn initSingle( .partitioned = false, }; - const items = try arena.dupe(*CookieStore.CookieListItem, &.{item}); - const event = try frame._factory.event( arena, type_string, CookieChangeEvent{ ._proto = undefined, - ._changed = if (kind == .changed) items else &.{}, - ._deleted = if (kind == .deleted) items else &.{}, + ._changed = if (kind == .changed) item[0..1] else &.{}, + ._deleted = if (kind == .deleted) item[0..1] else &.{}, }, ); @@ -116,11 +114,11 @@ pub fn asEvent(self: *CookieChangeEvent) *Event { return self._proto; } -pub fn getChanged(self: *const CookieChangeEvent) []*CookieStore.CookieListItem { +pub fn getChanged(self: *const CookieChangeEvent) []CookieStore.CookieListItem { return self._changed; } -pub fn getDeleted(self: *const CookieChangeEvent) []*CookieStore.CookieListItem { +pub fn getDeleted(self: *const CookieChangeEvent) []CookieStore.CookieListItem { return self._deleted; } From 27fe1d46d8ae0e05efc474ea8bfeecb20f5fabc8 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 28 May 2026 09:54:16 +0200 Subject: [PATCH 05/14] add missing .{ .dom_exception = true } into CookieStore bridge --- src/browser/webapi/storage/CookieStore.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/browser/webapi/storage/CookieStore.zig b/src/browser/webapi/storage/CookieStore.zig index 399e2fc7..529d0b02 100644 --- a/src/browser/webapi/storage/CookieStore.zig +++ b/src/browser/webapi/storage/CookieStore.zig @@ -431,10 +431,10 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; - pub const get = bridge.function(CookieStore.get, .{}); - pub const getAll = bridge.function(CookieStore.getAll, .{}); - pub const set = bridge.function(CookieStore.set, .{}); - pub const delete = bridge.function(CookieStore.delete, .{}); + pub const get = bridge.function(CookieStore.get, .{ .dom_exception = true }); + pub const getAll = bridge.function(CookieStore.getAll, .{ .dom_exception = true }); + pub const set = bridge.function(CookieStore.set, .{ .dom_exception = true }); + pub const delete = bridge.function(CookieStore.delete, .{ .dom_exception = true }); pub const onchange = bridge.accessor(CookieStore.getOnChange, CookieStore.setOnChange, .{}); }; From 6179c7dde554db971531e5924f547887ee8231cc Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 28 May 2026 11:14:52 +0200 Subject: [PATCH 06/14] replace Frame with Execution into CookieStore --- src/browser/webapi/Window.zig | 7 +- .../webapi/event/CookieChangeEvent.zig | 18 ++--- src/browser/webapi/storage/CookieStore.zig | 72 +++++++++---------- 3 files changed, 48 insertions(+), 49 deletions(-) diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index f06c1110..8c04a8e8 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -53,6 +53,7 @@ const log = lp.log; const IS_DEBUG = builtin.mode == .Debug; const Allocator = std.mem.Allocator; +const Execution = js.Execution; pub fn registerTypes() []const type { return &.{ Window, CrossOriginWindow }; @@ -206,10 +207,10 @@ pub fn getSessionStorage(self: *Window) *storage.Lookup { return &self._storage_bucket.session; } -pub fn getCookieStore(self: *Window, frame: *Frame) !*CookieStore { +pub fn getCookieStore(self: *Window, exec: *Execution) !*CookieStore { if (self._cookie_store) |cs| return cs; - const cs = try frame._factory.eventTarget(CookieStore{ ._proto = undefined }); - try cs.attachToFrame(frame); + const cs = try exec._factory.eventTarget(CookieStore{ ._proto = undefined }); + try cs.attach(exec); self._cookie_store = cs; return cs; } diff --git a/src/browser/webapi/event/CookieChangeEvent.zig b/src/browser/webapi/event/CookieChangeEvent.zig index f46d3044..3d78e5f4 100644 --- a/src/browser/webapi/event/CookieChangeEvent.zig +++ b/src/browser/webapi/event/CookieChangeEvent.zig @@ -20,7 +20,6 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); -const Frame = @import("../../Frame.zig"); const Notification = @import("../../../Notification.zig"); const Event = @import("../Event.zig"); @@ -29,6 +28,7 @@ const CookieStore = @import("../storage/CookieStore.zig"); const String = lp.String; const Allocator = std.mem.Allocator; +const Execution = js.Execution; // https://developer.mozilla.org/en-US/docs/Web/API/CookieChangeEvent const CookieChangeEvent = @This(); @@ -44,14 +44,14 @@ const CookieChangeEventOptions = struct { const Options = Event.inheritOptions(CookieChangeEvent, CookieChangeEventOptions); -pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*CookieChangeEvent { - const arena = try frame.getArena(.tiny, "CookieChangeEvent"); - errdefer frame.releaseArena(arena); +pub fn init(typ: []const u8, _opts: ?Options, exec: *const Execution) !*CookieChangeEvent { + const arena = try exec.getArena(.tiny, "CookieChangeEvent"); + errdefer exec.releaseArena(arena); const type_string = try String.init(arena, typ, .{}); const opts = _opts orelse Options{}; - const event = try frame._factory.event( + const event = try exec._factory.event( arena, type_string, CookieChangeEvent{ @@ -71,10 +71,10 @@ pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*CookieChangeEvent pub fn initSingle( kind: Notification.CookieChanged.Kind, snapshot: Notification.CookieChanged, - frame: *Frame, + exec: *const Execution, ) !*CookieChangeEvent { - const arena = try frame.getArena(.tiny, "CookieChangeEvent"); - errdefer frame.releaseArena(arena); + const arena = try exec.getArena(.tiny, "CookieChangeEvent"); + errdefer exec.releaseArena(arena); const type_string = try String.init(arena, "change", .{}); const item = try arena.create(CookieStore.CookieListItem); @@ -96,7 +96,7 @@ pub fn initSingle( .partitioned = false, }; - const event = try frame._factory.event( + const event = try exec._factory.event( arena, type_string, CookieChangeEvent{ diff --git a/src/browser/webapi/storage/CookieStore.zig b/src/browser/webapi/storage/CookieStore.zig index 529d0b02..669075b4 100644 --- a/src/browser/webapi/storage/CookieStore.zig +++ b/src/browser/webapi/storage/CookieStore.zig @@ -21,6 +21,7 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const URL = @import("../../URL.zig"); const Frame = @import("../../Frame.zig"); +const Session = @import("../../Session.zig"); const Notification = @import("../../../Notification.zig"); const Cookie = @import("Cookie.zig"); @@ -39,41 +40,36 @@ const CookieStore = @This(); _proto: *EventTarget, _on_change: ?js.Function.Global = null, -// Owning Frame, used to filter incoming change notifications by document -// scope and to schedule async event dispatch. Null when the store has no -// frame context (e.g. WorkerGlobalScope), in which case change events are -// not delivered. -_frame: ?*Frame = null, +_exec: ?*Execution = null, pub fn asEventTarget(self: *CookieStore) *EventTarget { return self._proto; } /// Registers this CookieStore as a listener for jar-change notifications on -/// the given frame's session. Must be called once after construction by the +/// the given session. Must be called once after construction by the /// owning Window. Idempotent on re-call. -pub fn attachToFrame(self: *CookieStore, frame: *Frame) !void { - if (self._frame != null) return; - self._frame = frame; - try frame._session.notification.register(.cookie_changed, self, onCookieChanged); +pub fn attach(self: *CookieStore, exec: *Execution) !void { + if (self._exec != null) return; + self._exec = exec; + try exec.session.notification.register(.cookie_changed, self, onCookieChanged); } -/// Removes this CookieStore from the notification list. Called from -/// Frame.deinit before tearing down the JS context. +/// Removes this CookieStore from the notification list. pub fn detach(self: *CookieStore) void { - const frame = self._frame orelse return; - frame._session.notification.unregisterAll(self); - self._frame = null; + const exec = self._exec orelse return; + exec.session.notification.unregisterAll(self); + self._exec = null; } fn onCookieChanged(ctx: *anyopaque, data: *const Notification.CookieChanged) !void { const self: *CookieStore = @ptrCast(@alignCast(ctx)); - const frame = self._frame orelse return; + const exec = self._exec orelse return; // CookieStore exposes only cookies that script would see for the // current document — same filter as `match` (HttpOnly hidden, // same-site treated as first-party against the document URL). - const doc_url = frame.url; + const doc_url = exec.url.*; const target = Cookie.PreparedUri{ .host = URL.getHostname(doc_url), .path = URL.getPathname(doc_url), @@ -97,12 +93,13 @@ fn onCookieChanged(ctx: *anyopaque, data: *const Notification.CookieChanged) !vo // Per spec, `change` is dispatched as a queued task — never synchronously // from the mutation site. We snapshot the notification fields onto a // small page arena that the scheduled callback releases after dispatch. - const arena = try frame.getArena(.tiny, "CookieStore.change"); - errdefer frame.releaseArena(arena); + const arena = try exec.getArena(.tiny, "CookieStore.change"); + errdefer exec.releaseArena(arena); const cb = try arena.create(ChangeCallback); cb.* = .{ .cookie_store = self, + .exec = exec, .arena = arena, .kind = data.kind, .name = try arena.dupe(u8, data.name), @@ -113,7 +110,7 @@ fn onCookieChanged(ctx: *anyopaque, data: *const Notification.CookieChanged) !vo .same_site = data.same_site, }; - try frame.js.scheduler.add(cb, ChangeCallback.run, 0, .{ + try exec.js.scheduler.add(cb, ChangeCallback.run, 0, .{ .name = "CookieStore.change", .low_priority = false, .finalizer = ChangeCallback.cancelled, @@ -122,6 +119,10 @@ fn onCookieChanged(ctx: *anyopaque, data: *const Notification.CookieChanged) !vo const ChangeCallback = struct { cookie_store: *CookieStore, + // Execution is stored only to ensure we can release the arena. + // The CookieStore could have been detached in the meantime, and so it's + // _exec pointer could have been reset. + exec: *Execution, arena: Allocator, kind: Notification.CookieChanged.Kind, name: []const u8, @@ -137,9 +138,7 @@ const ChangeCallback = struct { } fn releaseArena(self: *ChangeCallback) void { - if (self.cookie_store._frame) |frame| { - frame.releaseArena(self.arena); - } + self.exec.releaseArena(self.arena); } fn run(ctx: *anyopaque) !?u32 { @@ -147,11 +146,14 @@ const ChangeCallback = struct { defer self.releaseArena(); const cs = self.cookie_store; - const frame = cs._frame orelse return null; + // We use the CookieStore's exec here instead of self.exec to detect if + // the store has beend detached. In this case, we don't dispatch the + // event. + const exec = cs._exec orelse return null; const target = cs.asEventTarget(); // Skip event construction when nobody is listening. - if (!frame._event_manager.hasDirectListeners(target, "change", cs._on_change)) { + if (!exec.hasDirectListeners(target, "change", cs._on_change)) { return null; } @@ -164,9 +166,9 @@ const ChangeCallback = struct { .secure = self.secure, .http_only = false, .same_site = self.same_site, - }, frame); + }, exec); - try frame._event_manager.dispatchDirect(target, event.asEvent(), cs._on_change, .{ + try exec.dispatch(target, event.asEvent(), cs._on_change, .{ .context = "CookieStore.change", }); @@ -242,7 +244,7 @@ const SameSite = enum { }; pub fn get(_: *CookieStore, input: GetInput, exec: *const Execution) !js.Promise { - const local = exec.context.local.?; + const local = exec.js.local.?; const name: ?[]const u8 = switch (input) { .name => |n| n, @@ -260,7 +262,7 @@ pub fn get(_: *CookieStore, input: GetInput, exec: *const Execution) !js.Promise } pub fn getAll(_: *CookieStore, input: ?GetInput, exec: *const Execution) !js.Promise { - const local = exec.context.local.?; + const local = exec.js.local.?; const name: ?[]const u8 = if (input) |inp| switch (inp) { .name => |n| n, @@ -274,7 +276,7 @@ pub fn getAll(_: *CookieStore, input: ?GetInput, exec: *const Execution) !js.Pro } pub fn set(_: *CookieStore, input: SetInput, value: ?[]const u8, exec: *const Execution) !js.Promise { - const local = exec.context.local.?; + const local = exec.js.local.?; const init: CookieInit = switch (input) { .options => |o| o, @@ -292,7 +294,7 @@ pub fn set(_: *CookieStore, input: SetInput, value: ?[]const u8, exec: *const Ex } pub fn delete(_: *CookieStore, input: DeleteInput, exec: *const Execution) !js.Promise { - const local = exec.context.local.?; + const local = exec.js.local.?; const opts: DeleteOptions = switch (input) { .options => |o| o, @@ -322,9 +324,7 @@ fn matchCookies( name: ?[]const u8, first_only: bool, ) ![]*CookieListItem { - const session = switch (exec.context.global) { - inline else => |g| g._session, - }; + const session = exec.session; const url = exec.url.*; const target = Cookie.PreparedUri{ @@ -368,9 +368,7 @@ fn matchCookies( } fn storeCookie(exec: *const Execution, init: CookieInit) !void { - const session = switch (exec.context.global) { - inline else => |g| g._session, - }; + const session = exec.session; const url = exec.url.*; // Reuse the Set-Cookie parser by serialising the dict back into a From efde428cd458f9f23ee4f7554f3a7fd5743478e8 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 28 May 2026 12:29:16 +0200 Subject: [PATCH 07/14] Notify the deleted cookie on change --- src/browser/tests/cookie_store.html | 26 ++++++++++++++++++++++++++ src/browser/webapi/storage/Cookie.zig | 11 +++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/browser/tests/cookie_store.html b/src/browser/tests/cookie_store.html index 647e8831..a56a5ab6 100644 --- a/src/browser/tests/cookie_store.html +++ b/src/browser/tests/cookie_store.html @@ -114,6 +114,32 @@ }); + + + +