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 8e3ceb47..56ff821c 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/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..9838d7cb
--- /dev/null
+++ b/src/browser/tests/cookie_store.html
@@ -0,0 +1,216 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 f6bef76c..f49e1f95 100644
--- a/src/browser/webapi/Window.zig
+++ b/src/browser/webapi/Window.zig
@@ -42,6 +42,7 @@ const MessageEvent = @import("event/MessageEvent.zig");
const MessagePort = @import("MessagePort.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");
@@ -53,6 +54,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 };
@@ -72,6 +74,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,
@@ -241,6 +244,14 @@ pub fn getSessionStorage(self: *Window) *storage.Lookup {
return &self._storage_bucket.session;
}
+pub fn getCookieStore(self: *Window, exec: *Execution) !*CookieStore {
+ if (self._cookie_store) |cs| return cs;
+ const cs = try exec._factory.eventTarget(CookieStore{ ._proto = undefined });
+ try cs.attach(exec);
+ self._cookie_store = cs;
+ return cs;
+}
+
pub fn getOrigin(self: *const Window) []const u8 {
return self._frame.origin orelse "null";
}
@@ -921,6 +932,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 621993af..3a3608c0 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,
@@ -265,6 +267,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;
}
@@ -659,6 +668,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..0b4252fe
--- /dev/null
+++ b/src/browser/webapi/event/CookieChangeEvent.zig
@@ -0,0 +1,137 @@
+// 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 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;
+const Allocator = std.mem.Allocator;
+const Execution = js.Execution;
+
+// 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, 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 exec._factory.event(
+ arena,
+ type_string,
+ CookieChangeEvent{
+ ._proto = undefined,
+ ._changed = opts.changed orelse &.{},
+ ._deleted = opts.deleted orelse &.{},
+ },
+ );
+
+ Event.populatePrototypes(event, opts, false);
+ 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,
+ exec: *const Execution,
+) !*CookieChangeEvent {
+ 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);
+ item.* = .{
+ .name = try String.init(arena, snapshot.name, .{}),
+ .value = try String.init(arena, snapshot.value, .{}),
+ .domain = if (snapshot.domain.len > 0 and snapshot.domain[0] == '.')
+ try String.init(arena, snapshot.domain[1..], .{})
+ else
+ null,
+ .path = try String.init(arena, snapshot.path, .{}),
+ .expires = null,
+ .secure = snapshot.secure,
+ .sameSite = switch (snapshot.same_site) {
+ .strict => .strict,
+ .lax => .lax,
+ .none => .none,
+ },
+ .partitioned = false,
+ };
+
+ const event = try exec._factory.event(
+ arena,
+ type_string,
+ CookieChangeEvent{
+ ._proto = undefined,
+ ._changed = if (kind == .changed) item[0..1] else &.{},
+ ._deleted = if (kind == .deleted) item[0..1] else &.{},
+ },
+ );
+
+ Event.populatePrototypes(event, Options{}, true);
+ 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/Cookie.zig b/src/browser/webapi/storage/Cookie.zig
index 6eef2b57..1bfa75b6 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,
};
}
@@ -503,20 +509,48 @@ pub const Jar = struct {
return;
}
- c.deinit();
if (is_expired) {
+ // Dispatch while c still points at the live old cookie,
+ // then free its arena, then remove from the array. After
+ // swapRemove, items[i] holds a different (still-live)
+ // entry, so deinit must happen before that.
+ self.dispatchChange(.deleted, c);
+ c.deinit();
_ = self.cookies.swapRemove(i);
} else {
+ // Free the old cookie's arena before overwriting the slot;
+ // after the assignment, c points at the new cookie.
+ c.deinit();
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 +725,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 +757,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 +774,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 +836,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 +982,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
new file mode 100644
index 00000000..1e475286
--- /dev/null
+++ b/src/browser/webapi/storage/CookieStore.zig
@@ -0,0 +1,538 @@
+// 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 Frame = @import("../../Frame.zig");
+const Session = @import("../../Session.zig");
+const Notification = @import("../../../Notification.zig");
+
+const Cookie = @import("Cookie.zig");
+const EventTarget = @import("../EventTarget.zig");
+const CookieChangeEvent = @import("../event/CookieChangeEvent.zig");
+
+const Allocator = std.mem.Allocator;
+const Execution = js.Execution;
+const String = lp.String;
+
+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,
+_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 execution's session. Must be called once after construction by
+/// the owning global (Window today; WorkerGlobalScope once wired up).
+/// Idempotent on re-call.
+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.
+pub fn detach(self: *CookieStore) void {
+ 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 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 = exec.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 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),
+ .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 exec.js.scheduler.add(cb, ChangeCallback.run, 0, .{
+ .name = "CookieStore.change",
+ .low_priority = false,
+ .finalizer = ChangeCallback.cancelled,
+ });
+}
+
+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 its
+ // _exec pointer could have been reset.
+ exec: *Execution,
+ 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 {
+ self.exec.releaseArena(self.arena);
+ }
+
+ fn run(ctx: *anyopaque) !?u32 {
+ const self: *ChangeCallback = @ptrCast(@alignCast(ctx));
+ defer self.releaseArena();
+
+ const cs = self.cookie_store;
+ // We use the CookieStore's exec here instead of self.exec to detect if
+ // the store has been 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 (!exec.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,
+ }, exec);
+
+ try exec.dispatch(target, event.asEvent(), cs._on_change, .{
+ .context = "CookieStore.change",
+ });
+
+ return null;
+ }
+};
+
+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.js.local.?;
+
+ const name: ?[]const u8, const url: ?[]const u8 = switch (input) {
+ .name => |n| .{ n, null },
+ .options => |o| .{ o.name, o.url },
+ };
+
+ const items = matchCookies(exec, name, url, 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.js.local.?;
+
+ const name: ?[]const u8, const url: ?[]const u8 = if (input) |inp| switch (inp) {
+ .name => |n| .{ n, null },
+ .options => |o| .{ o.name, o.url },
+ } else .{ null, null };
+
+ const items = matchCookies(exec, name, url, 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.js.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.js.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({});
+}
+
+// Resolve the optional `url` per CookieStore.get/getAll spec. In a Window
+// context, only the document's own URL is allowed (matches the cookie scope
+// script already sees). In a Worker context, any same-origin URL is allowed.
+fn resolveQueryUrl(exec: *const Execution, _override: ?[]const u8) ![:0]const u8 {
+ const current = exec.url.*;
+ const override = _override orelse return current;
+
+ const resolved = try URL.resolve(exec.call_arena, exec.base(), override, .{ .always_dupe = true });
+ if (!exec.isSameOrigin(resolved)) return error.SecurityError;
+
+ switch (exec.js.global) {
+ .frame => {
+ if (!std.mem.eql(u8, resolved, current)) return error.InvalidUrl;
+ },
+ .worker => {},
+ }
+ return resolved;
+}
+
+fn matchCookies(
+ exec: *const Execution,
+ name: ?[]const u8,
+ url: ?[]const u8,
+ first_only: bool,
+) ![]*CookieListItem {
+ const session = exec.session;
+ const url_resolved = try resolveQueryUrl(exec, url);
+
+ const target = Cookie.PreparedUri{
+ .host = URL.getHostname(url_resolved),
+ .path = URL.getPathname(url_resolved),
+ .secure = URL.isHTTPS(url_resolved),
+ };
+ 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.arena.create(CookieListItem);
+ item.* = .{
+ .name = String.wrap(cookie.name),
+ .value = String.wrap(cookie.value),
+ .domain = if (cookie.domain.len > 0 and cookie.domain[0] == '.')
+ String.wrap(cookie.domain[1..])
+ else
+ null,
+ .path = String.wrap(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 = exec.session;
+ const url = exec.url.*;
+
+ // Reject inputs the cookie model can't represent. `=` is allowed in
+ // values but not in names; `;`/CR/LF/NUL break the cookie wire format
+ // everywhere and so are forbidden in every field.
+ if (init.name.len == 0) return error.InvalidCookieName;
+ if (std.mem.indexOfAny(u8, init.name, "=;\r\n\x00") != null) return error.InvalidCookieName;
+ if (std.mem.indexOfAny(u8, init.value, ";\r\n\x00") != null) return error.InvalidCookieValue;
+ if (std.mem.indexOfAny(u8, init.path, ";\r\n\x00") != null) return error.InvalidCookiePath;
+ if (init.domain) |d| {
+ if (std.mem.indexOfAny(u8, d, ";\r\n\x00") != null) return error.InvalidCookieDomain;
+ }
+
+ const is_https = URL.isHTTPS(url);
+ // Per spec, SameSite=None requires Secure. CookieStore additionally
+ // marks any cookie written from an HTTPS document as Secure.
+ const secure = is_https or init.sameSite == .none;
+
+ // Cookie-name-prefix rules — match Cookie.parse, case-insensitive to
+ // catch impersonation attempts (e.g. "__HoSt-").
+ // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#name-cookie-name-prefixes
+ if (std.ascii.startsWithIgnoreCase(init.name, "__Host-")) {
+ if (!is_https) return error.InvalidPrefixedCookie;
+ if (init.domain) |d| {
+ if (d.len > 0) return error.InvalidPrefixedCookie;
+ }
+ const effective_path = if (init.path.len > 0) init.path else "/";
+ if (!std.mem.eql(u8, effective_path, "/")) return error.InvalidPrefixedCookie;
+ } else if (std.ascii.startsWithIgnoreCase(init.name, "__Secure-")) {
+ if (!is_https) return error.InvalidPrefixedCookie;
+ }
+
+ var arena = std.heap.ArenaAllocator.init(session.cookie_jar.allocator);
+ errdefer arena.deinit();
+ const aa = arena.allocator();
+
+ const owned_name = try aa.dupe(u8, init.name);
+ const owned_value = try aa.dupe(u8, init.value);
+ const owned_path = try Cookie.parsePath(aa, url, init.path);
+ const owned_domain = try Cookie.parseDomain(aa, url, init.domain);
+
+ const cookie: Cookie = .{
+ .arena = arena,
+ .name = owned_name,
+ .value = owned_value,
+ .path = owned_path,
+ .domain = owned_domain,
+
+ // CookieStore.expires is a unix timestamp in milliseconds; Cookie tracks
+ // expiry in seconds. A timestamp at or before "now" deletes the cookie via
+ // the Jar's expiry path.
+ .expires = if (init.expires) |ms| ms / 1000.0 else null,
+
+ .secure = secure,
+ .http_only = false,
+ .same_site = switch (init.sameSite) {
+ .strict => .strict,
+ .lax => .lax,
+ .none => .none,
+ },
+ };
+
+ // 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, .{ .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, .{});
+};
+
+// 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: String,
+ value: String,
+ domain: ?String,
+ path: String,
+ expires: ?f64,
+ secure: bool,
+ sameSite: SameSite,
+ partitioned: bool,
+
+ fn getName(self: *const CookieListItem) String {
+ return self.name;
+ }
+ fn getValue(self: *const CookieListItem) String {
+ return self.value;
+ }
+ fn getDomain(self: *const CookieListItem) ?String {
+ return self.domain;
+ }
+ fn getPath(self: *const CookieListItem) String {
+ 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", .{});
+}
diff --git a/src/testing.zig b/src/testing.zig
index 3b4f4dce..d4df54c4 100644
--- a/src/testing.zig
+++ b/src/testing.zig
@@ -564,8 +564,11 @@ test "tests:afterAll" {
@import("root").v8_peak_memory = test_browser.env.isolate.getHeapStatistics().total_physical_size;
- test_notification.deinit();
+ // Browser must be deinit'd before the notification — Session/Frame
+ // teardown may unregister notification listeners (e.g. CookieStore
+ // detach), which dereferences `notification.listeners`.
test_browser.deinit();
+ test_notification.deinit();
test_app.deinit();
test_config.deinit(@import("root").tracking_allocator);
}