From b43c6087b7dbe43328be87e56ac4098a984ddcc2 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 18 Sep 2026 09:20:47 +0800 Subject: [PATCH] WebApi: Add Cache/CacheStore when ServiceWorker is enabled This adds the Cache and CacheStore APIs when ServiceWorker is enabled. Purely in-memory, tied to the Session's lifetime. These generally don't do unbounded growth, but simply load a fixed set of resources. In firefox, when ServiceWorker is disabled, the cache is still available on the Window. I tied it to the existing --experimental-features serviceworker flag (at least for now). --- src/browser/Session.zig | 7 +- src/browser/js/Env.zig | 35 +- src/browser/js/bridge.zig | 4 + src/browser/tests/cache/cache.html | 236 +++++++++++ src/browser/tests/cache/disabled-worker.js | 1 + src/browser/tests/cache/disabled.html | 30 ++ src/browser/tests/cache/teardown.html | 10 + .../tests/service_worker/cache-worker.js | 8 + .../tests/service_worker/service_worker.html | 24 ++ src/browser/webapi/Window.zig | 14 +- src/browser/webapi/WorkerGlobalScope.zig | 12 + src/browser/webapi/cache/Cache.zig | 371 ++++++++++++++++++ src/browser/webapi/cache/CacheStorage.zig | 106 +++++ src/browser/webapi/cache/Store.zig | 295 ++++++++++++++ src/browser/webapi/net/Fetch.zig | 65 ++- src/browser/webapi/net/Response.zig | 9 + 16 files changed, 1206 insertions(+), 21 deletions(-) create mode 100644 src/browser/tests/cache/cache.html create mode 100644 src/browser/tests/cache/disabled-worker.js create mode 100644 src/browser/tests/cache/disabled.html create mode 100644 src/browser/tests/cache/teardown.html create mode 100644 src/browser/tests/service_worker/cache-worker.js create mode 100644 src/browser/webapi/cache/Cache.zig create mode 100644 src/browser/webapi/cache/CacheStorage.zig create mode 100644 src/browser/webapi/cache/Store.zig diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 4c9cda141..64c4b43a4 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -25,6 +25,7 @@ const Config = @import("../Config.zig"); const History = @import("webapi/History.zig"); const storage = @import("webapi/storage/storage.zig"); const IdbManager = @import("webapi/storage/idb/idb.zig").Manager; +const CacheStore = @import("webapi/cache/Store.zig"); const Factory = @import("Factory.zig"); const EventTarget = @import("webapi/EventTarget.zig"); const Navigation = @import("webapi/navigation/Navigation.zig"); @@ -53,8 +54,8 @@ arena: *lp.Arena, history: History, navigation: *Navigation, storage_shed: storage.Shed, -// Per-origin IndexedDB engines -idb: IdbManager, +idb: IdbManager, // Per-origin IndexedDB engines +cache_store: CacheStore, // Per-origin CacheStorage // Backs `globalThis.lp.*`; values pre-stringified so the prelude splices // them in without re-encoding. bridge_store: std.StringHashMapUnmanaged([]const u8) = .empty, @@ -170,6 +171,7 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi .navigation = navigation, .storage_shed = .{}, .idb = IdbManager.init(allocator), + .cache_store = CacheStore.init(allocator), .browser = browser, .notification = notification, .cookie_jar = storage.Cookie.Jar.init(allocator, notification), @@ -213,6 +215,7 @@ pub fn deinit(self: *Session) void { self.storage_shed.deinit(self.browser.app.allocator); self.idb.deinit(); + self.cache_store.deinit(); { const allocator = self.browser.app.allocator; var it = self.bridge_store.iterator(); diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index f0b46ea29..ba8cfb4fa 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -320,10 +320,8 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context // Get the global object for the context const global_obj = v8.v8__Context__Global(v8_context).?; - if (comptime is_frame) { - if (global._session.experimental_features.serviceworker == false) { - self.hideServiceWorker(v8_context, global_obj); - } + if (global._session.experimental_features.serviceworker == false) { + self.hideServiceWorker(is_frame, v8_context, global_obj); } // Store our TAO inside the internal field of the global object. This @@ -430,17 +428,32 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context // its own Navigator.prototype (2 Gets + 1 Delete). // (If this proves to be an issue, we could swap the logic, and dynamically ADD // it when it is enabled, but that's a lot more code). -fn hideServiceWorker(self: *const Env, v8_context: *const v8.Context, global_obj: *const v8.Object) void { +fn hideServiceWorker(self: *const Env, comptime is_frame: bool, v8_context: *const v8.Context, global_obj: *const v8.Object) void { + if (comptime is_frame) { + self.deletePrototypeMember(v8_context, global_obj, "navigator", "service_worker"); + } + + // A [Global] interface's members are on its prototype and mirrored onto the + // global itself. + self.deletePrototypeMember(v8_context, global_obj, if (comptime is_frame) "window" else "worker_global_scope", "caches"); + var deleted: v8.MaybeBool = undefined; + v8.v8__Object__Delete(global_obj, v8_context, @ptrCast(self.disabled_api_names.get(self.isolate.handle, "caches")), &deleted); + if (deleted.has_value == false or deleted.value == false) { + log.warn(.js, "failed to hide experimental API", .{ .interface = "global", .member = "caches" }); + } +} + +fn deletePrototypeMember(self: *const Env, v8_context: *const v8.Context, global_obj: *const v8.Object, comptime interface: []const u8, comptime member: []const u8) void { const isolate = self.isolate.handle; const names = &self.disabled_api_names; - const constructor = v8.v8__Object__Get(global_obj, v8_context, @ptrCast(names.get(isolate, "navigator"))) orelse return; + const constructor = v8.v8__Object__Get(global_obj, v8_context, @ptrCast(names.get(isolate, interface))) orelse return; const prototype = v8.v8__Object__Get(@ptrCast(constructor), v8_context, @ptrCast(names.get(isolate, "prototype"))) orelse return; var deleted: v8.MaybeBool = undefined; - v8.v8__Object__Delete(@ptrCast(prototype), v8_context, @ptrCast(names.get(isolate, "service_worker")), &deleted); + v8.v8__Object__Delete(@ptrCast(prototype), v8_context, @ptrCast(names.get(isolate, member)), &deleted); if (deleted.has_value == false or deleted.value == false) { - log.warn(.js, "failed to hide navigator.serviceWorker", .{}); + log.warn(.js, "failed to hide experimental API", .{ .interface = interface, .member = member }); } } @@ -756,14 +769,20 @@ fn oomCallback(c_location: [*c]const u8, details: ?*const v8.OOMDetails) callcon const DisabledApiNames = struct { navigator: v8.Eternal, + window: v8.Eternal, + worker_global_scope: v8.Eternal, prototype: v8.Eternal, service_worker: v8.Eternal, + caches: v8.Eternal, fn init(isolate: *v8.Isolate) DisabledApiNames { var self: DisabledApiNames = undefined; intern(isolate, &self.navigator, "Navigator"); + intern(isolate, &self.window, "Window"); + intern(isolate, &self.worker_global_scope, "WorkerGlobalScope"); intern(isolate, &self.prototype, "prototype"); intern(isolate, &self.service_worker, "serviceWorker"); + intern(isolate, &self.caches, "caches"); return self; } diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 95dbe17c2..1098779c7 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -1227,6 +1227,8 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/storage/storage.zig"), @import("../webapi/storage/CookieStore.zig"), @import("../webapi/storage/idb/idb.zig"), + @import("../webapi/cache/CacheStorage.zig"), + @import("../webapi/cache/Cache.zig"), @import("../webapi/event/CookieChangeEvent.zig"), @import("../webapi/URL.zig"), @import("../webapi/URLPattern.zig"), @@ -1342,6 +1344,8 @@ const worker_common_apis = [_]type{ @import("../webapi/Performance.zig"), @import("../webapi/PerformanceObserver.zig"), @import("../webapi/storage/idb/idb.zig"), + @import("../webapi/cache/CacheStorage.zig"), + @import("../webapi/cache/Cache.zig"), @import("../webapi/BroadcastChannel.zig"), @import("../webapi/event/CustomEvent.zig"), @import("../webapi/event/ProgressEvent.zig"), diff --git a/src/browser/tests/cache/cache.html b/src/browser/tests/cache/cache.html new file mode 100644 index 000000000..654a141a7 --- /dev/null +++ b/src/browser/tests/cache/cache.html @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + diff --git a/src/browser/tests/cache/disabled-worker.js b/src/browser/tests/cache/disabled-worker.js new file mode 100644 index 000000000..aa80b5f97 --- /dev/null +++ b/src/browser/tests/cache/disabled-worker.js @@ -0,0 +1 @@ +postMessage({ present: 'caches' in self, constructor: typeof CacheStorage }); diff --git a/src/browser/tests/cache/disabled.html b/src/browser/tests/cache/disabled.html new file mode 100644 index 000000000..a48b6d8ad --- /dev/null +++ b/src/browser/tests/cache/disabled.html @@ -0,0 +1,30 @@ + + + + + + diff --git a/src/browser/tests/cache/teardown.html b/src/browser/tests/cache/teardown.html new file mode 100644 index 000000000..5a9e7a6d1 --- /dev/null +++ b/src/browser/tests/cache/teardown.html @@ -0,0 +1,10 @@ + + + + diff --git a/src/browser/tests/service_worker/cache-worker.js b/src/browser/tests/service_worker/cache-worker.js new file mode 100644 index 000000000..fcc82e99b --- /dev/null +++ b/src/browser/tests/service_worker/cache-worker.js @@ -0,0 +1,8 @@ +// The canonical install handler: precache, and hold install open until it's +// done. A throw (no `caches`, a failed fetch) stops the worker before it +// activates. +self.addEventListener('install', (e) => { + e.waitUntil( + caches.open('sw-precache').then((cache) => cache.addAll(['./lifecycle-worker.js', '/xhr/json'])) + ); +}); diff --git a/src/browser/tests/service_worker/service_worker.html b/src/browser/tests/service_worker/service_worker.html index d0d793cee..0fbc7d528 100644 --- a/src/browser/tests/service_worker/service_worker.html +++ b/src/browser/tests/service_worker/service_worker.html @@ -367,3 +367,27 @@ }); } + + diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 3953e7bda..cb7fc6d07 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -41,9 +41,10 @@ const ErrorEvent = @import("event/ErrorEvent.zig"); const MessageEvent = @import("event/MessageEvent.zig"); const MessagePort = @import("MessagePort.zig"); const MediaQueryList = @import("css/MediaQueryList.zig"); -const storage = @import("storage/storage.zig"); const idb = @import("storage/idb/idb.zig"); +const storage = @import("storage/storage.zig"); const CookieStore = @import("storage/CookieStore.zig"); +const CacheStorage = @import("cache/CacheStorage.zig"); const Element = @import("Element.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); const CustomElementRegistry = @import("CustomElementRegistry.zig"); @@ -77,6 +78,7 @@ _visual_viewport: *VisualViewport, _performance: *Performance, _cookie_store: ?*CookieStore = null, _idb_factory: ?*idb.IDBFactory = null, +_caches: ?*CacheStorage = null, _on_load: ?js.Function.Global = null, _on_pageshow: ?js.Function.Global = null, _on_popstate: ?js.Function.Global = null, @@ -317,6 +319,15 @@ fn getIndexedDB(self: *Window, exec: *Execution) !*idb.IDBFactory { return f; } +fn getCaches(self: *Window, exec: *Execution) !*CacheStorage { + if (self._caches) |c| { + return c; + } + const c = try exec._factory.create(CacheStorage{}); + self._caches = c; + return c; +} + pub fn getOrigin(self: *const Window) []const u8 { return self._frame.origin orelse "null"; } @@ -1165,6 +1176,7 @@ pub const JsApi = struct { pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{}); pub const cookieStore = bridge.accessor(Window.getCookieStore, null, .{}); pub const indexedDB = bridge.accessor(Window.getIndexedDB, null, .{}); + pub const caches = bridge.accessor(Window.getCaches, null, .{}); pub const origin = bridge.accessor(Window.getOrigin, Window.setOrigin, .{}); 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 229d047c2..4c8b05a88 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -46,6 +46,7 @@ const WorkerLocation = @import("WorkerLocation.zig"); const ErrorEvent = @import("event/ErrorEvent.zig"); const Fetch = @import("net/Fetch.zig"); const idb = @import("storage/idb/idb.zig"); +const CacheStorage = @import("cache/CacheStorage.zig"); const MessagePort = @import("MessagePort.zig"); const SharedWorkerGlobalScope = @import("SharedWorkerGlobalScope.zig"); const ServiceWorkerGlobalScope = @import("ServiceWorkerGlobalScope.zig"); @@ -109,6 +110,7 @@ _crypto: Crypto = .init, _navigator: WorkerNavigator = .init, _performance: *Performance, _idb_factory: ?*idb.IDBFactory = null, +_caches: ?*CacheStorage = null, _on_error: ?JS.Function.Global = null, _on_rejection_handled: ?JS.Function.Global = null, _on_unhandled_rejection: ?JS.Function.Global = null, @@ -545,6 +547,15 @@ fn clearInterval(self: *WorkerGlobalScope, id: u32) void { self._timers.clear(id); } +fn getCaches(self: *WorkerGlobalScope, exec: *JS.Execution) !*CacheStorage { + if (self._caches) |c| { + return c; + } + const c = try exec._factory.create(CacheStorage{}); + self._caches = c; + return c; +} + fn getIndexedDB(self: *WorkerGlobalScope, exec: *JS.Execution) !*idb.IDBFactory { if (self._idb_factory) |f| { return f; @@ -600,6 +611,7 @@ pub const JsApi = struct { pub const self = bridge.accessor(WorkerGlobalScope.getSelf, WorkerGlobalScope.setSelf, .{}); pub const location = bridge.accessor(WorkerGlobalScope.getLocation, null, .{}); pub const indexedDB = bridge.accessor(WorkerGlobalScope.getIndexedDB, null, .{}); + pub const caches = bridge.accessor(WorkerGlobalScope.getCaches, 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/cache/Cache.zig b/src/browser/webapi/cache/Cache.zig new file mode 100644 index 000000000..ca26e1551 --- /dev/null +++ b/src/browser/webapi/cache/Cache.zig @@ -0,0 +1,371 @@ +// 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 http = @import("../../../network/http.zig"); + +const Fetch = @import("../net/Fetch.zig"); +const Headers = @import("../net/Headers.zig"); +const Request = @import("../net/Request.zig"); +const Response = @import("../net/Response.zig"); + +const Store = @import("Store.zig"); + +const log = lp.log; +const Execution = js.Execution; +const Allocator = std.mem.Allocator; + +// Entries are matched on their url alone: no Vary, and only GET is ever stored. +const Cache = @This(); + +_origin: *Store.Origin, +_bucket: *Store.Bucket, + +const QueryOptions = struct { + ignoreMethod: bool = false, // TODO + ignoreSearch: bool = false, + ignoreVary: bool = false, // TODO +}; + +pub fn init(origin: *Store.Origin, bucket: *Store.Bucket, exec: *const Execution) !*Cache { + return exec._factory.create(Cache{ ._origin = origin, ._bucket = bucket }); +} + +fn match(self: *Cache, input: Request.Input, opts_: ?QueryOptions, exec: *const Execution) !js.Promise { + const opts = opts_ orelse QueryOptions{}; + return matchIn(&.{self._bucket}, input, opts.ignoreSearch, exec); +} + +pub fn matchIn(buckets: []const *Store.Bucket, input: Request.Input, ignore_search: bool, exec: *const Execution) !js.Promise { + const local = exec.js.local.?; + const key = try Key.init(input, exec); + if (key.method == .GET) { + for (buckets) |bucket| { + const entry = bucket.match(key.url, ignore_search) orelse continue; + const response = try toResponse(entry, exec); + response.acquireRef(); + defer response.releaseRef(exec.page); // safely transfered to v8, release + return local.resolvePromise(response); + } + } + return local.resolvePromise({}); +} + +fn put(self: *Cache, input: Request.Input, response: *Response, exec: *const Execution) !js.Promise { + // Errors reject: the bridge turns an error from a promise-returning + // function into a rejection. + const local = exec.js.local.?; + const key = try Key.init(input, exec); + if (key.storable() == false) { + return local.typeError("Request must be an http(s) GET"); + } + if (unstorable(response)) |reason| { + return local.typeError(reason); + } + const body = try response.consumeBytes(exec); + + const allocator = self._origin.allocator; + const entry = try toEntry(allocator, key.url, response, body); + errdefer entry.deinit(); + + try self._bucket.put(allocator, entry); + return local.resolvePromise({}); +} + +fn delete(self: *Cache, input: Request.Input, opts_: ?QueryOptions, exec: *const Execution) !js.Promise { + const opts = opts_ orelse QueryOptions{}; + const key = try Key.init(input, exec); + var deleted = false; + if (key.method == .GET) { + deleted = self._bucket.delete(key.url, opts.ignoreSearch); + } + return exec.js.local.?.resolvePromise(deleted); +} + +fn keys(self: *Cache, exec: *const Execution) !js.Promise { + const entries = self._bucket.entries.values(); + + // call_arena: resolving runs microtasks, and a nested native call resets + // local_arena before the defer below gets to run. + var requests: std.ArrayList(*Request) = try .initCapacity(exec.call_arena, entries.len); + defer for (requests.items) |request| { + // once we know they've safely been transfered to v8, we can release them + request.releaseRef(exec.page); + }; + + for (entries) |entry| { + const url = try exec.call_arena.dupeZ(u8, entry.url); + const request = try Request.init(.{ .url = url }, null, exec); + request.acquireRef(); + requests.appendAssumeCapacity(request); + } + return exec.js.local.?.resolvePromise(requests.items); +} + +fn add(self: *Cache, input: Request.Input, exec: *const Execution) !js.Promise { + return self.addAll(&.{input}, exec); +} + +fn addAll(self: *Cache, inputs: []const Request.Input, exec: *const Execution) !js.Promise { + const local = exec.js.local.?; + + // call_arena: batch.release() below can settle the promise synchronously, + // and that runs microtasks (see keys). + const requests = try exec.call_arena.alloc(*Request, inputs.len); + var added_count: usize = 0; + defer for (requests[0..added_count]) |request| { + request.releaseRef(exec.page); + }; + + for (inputs, requests) |input, *request| { + request.* = try Request.init(input, null, exec); + request.*.acquireRef(); + added_count += 1; + + const key: Key = .{ .url = request.*._url, .method = request.*._method }; + if (key.storable() == false) { + return local.typeError("Request must be an http(s) GET"); + } + } + + const resolver = local.createPromiseResolver(); + const batch = try Batch.init(self, try resolver.persist(), requests, exec); + for (requests, batch.slots) |request, *slot| { + batch.pending += 1; + Fetch.start(request, .{ .ctx = slot, .callback = Batch.fetched }, exec) catch |err| { + log.warn(.http, "Cache.add fetch", .{ .err = err, .url = request._url }); + batch.pending -= 1; + batch.outcome = .failed; + break; + }; + } + batch.release(); + return resolver.promise(); +} + +// An addAll in flight. +const Batch = struct { + arena: *lp.Arena, + exec: *const Execution, + origin: *Store.Origin, + bucket: *Store.Bucket, + resolver: js.PromiseResolver.Global, + slots: []Slot, + staged: std.ArrayList(*Store.Entry), + outcome: enum { ok, failed, shutdown } = .ok, + pending: usize = 1, + + const Slot = struct { + batch: *Batch, + url: []const u8, + }; + + fn init(cache: *const Cache, resolver: js.PromiseResolver.Global, requests: []const *Request, exec: *const Execution) !*Batch { + const arena = try exec.getArena(.small, "Cache.addAll"); + errdefer arena.release(); + + const self = try arena.create(Batch); + self.* = .{ + .arena = arena, + .exec = exec, + .origin = cache._origin, + .bucket = cache._bucket, + .resolver = resolver, + .slots = try arena.alloc(Slot, requests.len), + .staged = try .initCapacity(arena.allocator(), requests.len), + }; + for (requests, self.slots) |request, *slot| { + slot.* = .{ .batch = self, .url = try arena.dupe(u8, request._url) }; + } + return self; + } + + // callback from Fetch on complete/error/shutdown + fn fetched(ctx: *anyopaque, result: Fetch.Completion.Result) void { + const slot: *Slot = @ptrCast(@alignCast(ctx)); + const self = slot.batch; + defer self.release(); + + switch (result) { + .done => |response| if (self.outcome == .ok) { + self.stage(slot.url, response) catch { + self.outcome = .failed; + }; + }, + .err => if (self.outcome == .ok) { + self.outcome = .failed; + }, + .shutdown => self.outcome = .shutdown, + } + } + + fn stage(self: *Batch, url: []const u8, response: *const Response) !void { + if (response._status < 200 or response._status > 299 or unstorable(response) != null) { + return error.Unstorable; + } + const entry = try toEntry(self.origin.allocator, url, response, response._body.bytes); + self.staged.appendAssumeCapacity(entry); + } + + fn release(self: *Batch) void { + self.pending -= 1; + if (self.pending > 0) { + return; + } + defer self.arena.release(); + + if (self.outcome == .ok) { + self.commit() catch { + self.outcome = .failed; + }; + } + for (self.staged.items) |entry| { + entry.deinit(); + } + + if (self.outcome == .shutdown) { + return; + } + + var ls: js.Local.Scope = undefined; + self.exec.js.localScope(&ls); + defer ls.deinit(); + + const resolver = ls.toLocal(self.resolver); + switch (self.outcome) { + .ok => resolver.resolve("Cache.addAll", {}), + .failed => resolver.rejectError("Cache.addAll", .{ .type_error = "Request failed" }), + .shutdown => unreachable, + } + } + + fn commit(self: *Batch) !void { + const allocator = self.origin.allocator; + try self.bucket.entries.ensureUnusedCapacity(allocator, self.staged.items.len); + for (self.staged.items) |entry| { + self.bucket.put(allocator, entry) catch unreachable; + } + self.staged.clearRetainingCapacity(); + } +}; + +// What an entry is looked up by. +const Key = struct { + url: [:0]const u8, + method: http.Method, + + fn init(input: Request.Input, exec: *const Execution) !Key { + return switch (input) { + .request => |r| .{ .url = r._url, .method = r._method }, + .url => |u| .{ + .url = try URL.resolve(exec.call_arena, exec.base(), u, .{ .encoding = exec.charset.* }), + .method = .GET, + }, + }; + } + + fn storable(self: Key) bool { + if (self.method != .GET) { + return false; + } + return std.mem.startsWith(u8, self.url, "http://") or std.mem.startsWith(u8, self.url, "https://"); + } +}; + +// Why this response can't be stored, if it can't. +fn unstorable(response: *const Response) ?[]const u8 { + if (response._status == 206) { + return "Partial response (status code 206) is unsupported"; + } + if (response._body == .stream) { + return "Response with a ReadableStream body is unsupported"; + } + for (response._headers._list._entries.items) |*header| { + if (std.ascii.eqlIgnoreCase(header.name.str(), "vary") == false) { + continue; + } + var it = std.mem.splitScalar(u8, header.value.str(), ','); + while (it.next()) |value| { + if (std.mem.eql(u8, std.mem.trim(u8, value, " \t"), "*")) { + return "Vary header contains *"; + } + } + } + return null; +} + +fn toEntry(allocator: Allocator, url: []const u8, response: *const Response, body: []const u8) !*Store.Entry { + const entry = try Store.Entry.create(allocator, url); + errdefer entry.deinit(); + + const arena = entry.arena.allocator(); + const source = response._headers._list._entries.items; + const headers = try arena.alloc([2][]const u8, source.len); + for (source, headers) |*header, *kv| { + kv.* = .{ try arena.dupe(u8, header.name.str()), try arena.dupe(u8, header.value.str()) }; + } + + entry.response_url = try arena.dupeZ(u8, response._url); + entry.status = response._status; + entry.status_text = try arena.dupe(u8, response._status_text); + entry.response_type = response._type; + entry.is_redirected = response._is_redirected; + entry.headers = headers; + entry.body = try arena.dupe(u8, body); + return entry; +} + +fn toResponse(entry: *const Store.Entry, exec: *const Execution) !*Response { + const arena = try exec.getPinnedArena(entry.body.len + entry.response_url.len + 256, "Cache.match"); + errdefer arena.release(); + + const response = try arena.create(Response); + response.* = .{ + ._arena = arena, + ._status = entry.status, + ._status_text = try arena.dupe(u8, entry.status_text), + ._url = try arena.dupeZ(u8, entry.response_url), + ._body = .{ .bytes = try arena.dupe(u8, entry.body) }, + ._type = entry.response_type, + ._is_redirected = entry.is_redirected, + ._headers = try .initGuarded(.{ .strings = entry.headers }, .immutable, exec), + }; + arena.report(); + return response; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Cache); + + pub const Meta = struct { + pub const name = "Cache"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const match = bridge.function(Cache.match, .{}); + pub const add = bridge.function(Cache.add, .{}); + pub const addAll = bridge.function(Cache.addAll, .{}); + pub const put = bridge.function(Cache.put, .{}); + pub const delete = bridge.function(Cache.delete, .{}); + pub const keys = bridge.function(Cache.keys, .{}); +}; diff --git a/src/browser/webapi/cache/CacheStorage.zig b/src/browser/webapi/cache/CacheStorage.zig new file mode 100644 index 000000000..ba01f98ca --- /dev/null +++ b/src/browser/webapi/cache/CacheStorage.zig @@ -0,0 +1,106 @@ +// Copyright (C) 2023-2025 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 js = @import("../../js/js.zig"); + +const Request = @import("../net/Request.zig"); + +const Cache = @import("Cache.zig"); +const Store = @import("Store.zig"); + +const Execution = js.Execution; + +const CacheStorage = @This(); + +_pad: bool = false, + +fn open(_: *CacheStorage, name: []const u8, exec: *const Execution) !js.Promise { + const origin = try storeOrigin(exec); + const bucket = try origin.open(name); + return exec.js.local.?.resolvePromise(try Cache.init(origin, bucket, exec)); +} + +fn has(_: *CacheStorage, name: []const u8, exec: *const Execution) !js.Promise { + const origin = try storeOrigin(exec); + return exec.js.local.?.resolvePromise(origin.find(name) != null); +} + +fn delete(_: *CacheStorage, name: []const u8, exec: *const Execution) !js.Promise { + const origin = try storeOrigin(exec); + return exec.js.local.?.resolvePromise(try origin.delete(name)); +} + +fn keys(_: *CacheStorage, exec: *const Execution) !js.Promise { + const origin = try storeOrigin(exec); + return exec.js.local.?.resolvePromise(origin.buckets.keys()); +} + +const MatchOptions = struct { + cacheName: ?[]const u8 = null, + ignoreMethod: bool = false, // TODO + ignoreSearch: bool = false, + ignoreVary: bool = false, // TODO +}; + +fn match(_: *CacheStorage, input: Request.Input, opts_: ?MatchOptions, exec: *const Execution) !js.Promise { + const origin = try storeOrigin(exec); + const opts = opts_ orelse MatchOptions{}; + if (opts.cacheName) |name| { + const bucket = origin.find(name) orelse return exec.js.local.?.resolvePromise({}); + return Cache.matchIn(&.{bucket}, input, opts.ignoreSearch, exec); + } + return Cache.matchIn(origin.buckets.values(), input, opts.ignoreSearch, exec); +} + +// Unavailable for an opaque origin, e.g. about:blank. The bridge turns the +// error into a rejection. +fn storeOrigin(exec: *const Execution) !*Store.Origin { + const origin = exec.origin() orelse return error.SecurityError; + return exec.session.cache_store.forOrigin(origin); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(CacheStorage); + + pub const Meta = struct { + pub const name = "CacheStorage"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + + pub const open = bridge.function(CacheStorage.open, .{}); + pub const has = bridge.function(CacheStorage.has, .{}); + pub const delete = bridge.function(CacheStorage.delete, .{}); + pub const keys = bridge.function(CacheStorage.keys, .{}); + pub const match = bridge.function(CacheStorage.match, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: CacheStorage" { + testing.silenceLog(&.{.http}); // addAll's 404 case + try testing.htmlRunner("cache/cache.html", .{ .experimental_features = .{ .serviceworker = true } }); +} + +test "WebApi: CacheStorage addAll torn down in flight" { + try testing.htmlRunner("cache/teardown.html", .{ .experimental_features = .{ .serviceworker = true } }); +} + +test "WebApi: CacheStorage disabled" { + try testing.htmlRunner("cache/disabled.html", .{}); +} diff --git a/src/browser/webapi/cache/Store.zig b/src/browser/webapi/cache/Store.zig new file mode 100644 index 000000000..64d519f9d --- /dev/null +++ b/src/browser/webapi/cache/Store.zig @@ -0,0 +1,295 @@ +// 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 URL = @import("../../URL.zig"); +const Response = @import("../net/Response.zig"); + +const Allocator = std.mem.Allocator; + +// The Session's CacheStorage data: origin -> named caches -> entries. Plain +// bytes only, no JS objects, so a Response stored by one realm can be served +// to another. Memory only, for the life of the Session. +const Store = @This(); + +allocator: Allocator, +// origin -> buckets -> url -> Entry +origins: std.StringHashMapUnmanaged(*Origin) = .empty, + +pub fn init(allocator: Allocator) Store { + return .{ .allocator = allocator }; +} + +pub fn deinit(self: *Store) void { + const allocator = self.allocator; + var it = self.origins.iterator(); + while (it.next()) |kv| { + const origin = kv.value_ptr.*; + for (origin.buckets.values()) |bucket| { + bucket.deinit(allocator); + } + for (origin.deleted.items) |bucket| { + bucket.deinit(allocator); + } + origin.buckets.deinit(allocator); + origin.deleted.deinit(allocator); + allocator.destroy(origin); + allocator.free(kv.key_ptr.*); + } + self.origins.deinit(allocator); +} + +pub fn forOrigin(self: *Store, origin: []const u8) !*Origin { + const gop = try self.origins.getOrPut(self.allocator, origin); + if (gop.found_existing) { + return gop.value_ptr.*; + } + errdefer _ = self.origins.remove(origin); + + const owned = try self.allocator.dupe(u8, origin); + errdefer self.allocator.free(owned); + + const o = try self.allocator.create(Origin); + o.* = .{ .allocator = self.allocator }; + + gop.key_ptr.* = owned; + gop.value_ptr.* = o; + return o; +} + +pub const Origin = struct { + allocator: Allocator, + buckets: std.StringArrayHashMapUnmanaged(*Bucket) = .empty, + // A Cache object outlives caches.delete, so we need to keep deleted buckets + deleted: std.ArrayList(*Bucket) = .empty, + + pub fn find(self: *const Origin, name: []const u8) ?*Bucket { + return self.buckets.get(name); + } + + pub fn open(self: *Origin, name: []const u8) !*Bucket { + if (self.find(name)) |bucket| { + return bucket; + } + const allocator = self.allocator; + try self.buckets.ensureUnusedCapacity(allocator, 1); + + const owned = try allocator.dupe(u8, name); + errdefer allocator.free(owned); + + const bucket = try allocator.create(Bucket); + bucket.* = .{ .name = owned }; + self.buckets.putAssumeCapacity(owned, bucket); + return bucket; + } + + pub fn delete(self: *Origin, name: []const u8) !bool { + const bucket = self.find(name) orelse return false; + try self.deleted.append(self.allocator, bucket); + _ = self.buckets.orderedRemove(name); + bucket.clear(self.allocator); + return true; + } +}; + +pub const Bucket = struct { + name: []const u8, + entries: std.StringArrayHashMapUnmanaged(*Entry) = .empty, + + fn deinit(self: *Bucket, allocator: Allocator) void { + self.clear(allocator); + allocator.free(self.name); + allocator.destroy(self); + } + + fn clear(self: *Bucket, allocator: Allocator) void { + for (self.entries.values()) |entry| { + entry.deinit(); + } + self.entries.clearAndFree(allocator); + } + + pub fn match(self: *const Bucket, url: []const u8, ignore_search: bool) ?*const Entry { + const idx = self.indexOf(url, ignore_search, 0) orelse return null; + return self.entries.values()[idx]; + } + + pub fn put(self: *Bucket, allocator: Allocator, entry: *Entry) !void { + try self.entries.ensureUnusedCapacity(allocator, 1); + // remove and add, so that it gets placed at the end + _ = self.delete(entry.url, false); + self.entries.putAssumeCapacity(entry.url, entry); + } + + pub fn delete(self: *Bucket, url: []const u8, ignore_search: bool) bool { + if (ignore_search == false) { + const idx = self.indexOf(url, ignore_search, 0) orelse return false; + self.deleteAtIndex(idx); + return true; + } + + // when we ignore_search, we can find multiple entries, e.g.: + // www.example.com/page.js?id=1 + // www.example.com/page.js?id=2 + var deleted = false; + var last_index: usize = 0; + while (self.indexOf(url, ignore_search, last_index)) |idx| { + self.deleteAtIndex(idx); + deleted = true; + last_index = idx; + } + return deleted; + } + + fn deleteAtIndex(self: *Bucket, idx: usize) void { + const entry = self.entries.values()[idx]; + self.entries.orderedRemoveAt(idx); + entry.deinit(); + } + + fn indexOf(self: *const Bucket, url: []const u8, ignore_search: bool, start: usize) ?usize { + if (ignore_search == false) { + return self.entries.getIndex(URL.stripFragment(url)); + } + const needle = withoutSearch(url); + for (self.entries.values()[start..], start..) |entry, i| { + if (std.mem.eql(u8, entry.url_no_search, needle)) { + return i; + } + } + return null; + } +}; + +fn withoutSearch(url: []const u8) []const u8 { + return url[0 .. std.mem.indexOfAny(u8, url, "?#") orelse url.len]; +} + +// A stored request/response pair. Only the request's url is kept: entries are +// always GET and Vary isn't considered. +pub const Entry = struct { + arena: std.heap.ArenaAllocator, + status: u16, + status_text: []const u8, + is_redirected: bool, + url: []const u8, // without its fragment. + url_no_search: []const u8, // slices into url, for ignoreSearch. + response_url: [:0]const u8, + response_type: Response.Type, + headers: []const [2][]const u8, + body: []const u8, + + pub fn create(allocator: Allocator, url: []const u8) !*Entry { + var arena = std.heap.ArenaAllocator.init(allocator); + errdefer arena.deinit(); + + const owned_url = try arena.allocator().dupe(u8, URL.stripFragment(url)); + const self = try arena.allocator().create(Entry); + self.* = .{ + .arena = arena, + .url = owned_url, + .url_no_search = withoutSearch(owned_url), + .response_url = "", + .status = 200, + .status_text = "", + .response_type = .default, + .is_redirected = false, + .headers = &.{}, + .body = "", + }; + return self; + } + + pub fn deinit(self: *Entry) void { + self.arena.deinit(); + } +}; + +const testing = @import("../../../testing.zig"); +test "Cache - Store: origins and buckets" { + var store = Store.init(testing.allocator); + defer store.deinit(); + + const a = try store.forOrigin("https://a.com"); + try testing.expect(a == try store.forOrigin("https://a.com")); + try testing.expect(a != try store.forOrigin("https://b.com")); + + const v1 = try a.open("v1"); + try testing.expect(v1 == try a.open("v1")); + _ = try a.open("v2"); + try testing.expectEqual(2, a.buckets.count()); + + try v1.put(a.allocator, try testEntry("https://a.com/x", "x")); + try testing.expectEqual(true, try a.delete("v1")); + try testing.expectEqual(false, try a.delete("v1")); + try testing.expectEqual(null, a.find("v1")); + + // a deleted bucket stays usable + try testing.expectEqual(0, v1.entries.count()); + try v1.put(a.allocator, try testEntry("https://a.com/x", "x")); +} + +test "Cache - Store: put, match, delete" { + var store = Store.init(testing.allocator); + defer store.deinit(); + + const origin = try store.forOrigin("https://a.com"); + const bucket = try origin.open("v1"); + + try bucket.put(origin.allocator, try testEntry("https://a.com/x?a=1", "one")); + try bucket.put(origin.allocator, try testEntry("https://a.com/x?a=2", "two")); + try bucket.put(origin.allocator, try testEntry("https://a.com/x?a=1", "three")); + try testing.expectEqual(2, bucket.entries.count()); + + try testing.expectEqual("three", bucket.match("https://a.com/x?a=1#frag", false).?.body); + try testing.expectEqual(null, bucket.match("https://a.com/x", false)); + try testing.expectEqual("two", bucket.match("https://a.com/x", true).?.body); + + try testing.expectEqual(false, bucket.delete("https://a.com/x", false)); + try testing.expectEqual(true, bucket.delete("https://a.com/x?b=3", true)); + try testing.expectEqual(0, bucket.entries.count()); +} + +test "Cache - Store: ignoreSearch delete removes every match" { + var store = Store.init(testing.allocator); + defer store.deinit(); + + const origin = try store.forOrigin("https://a.com"); + const bucket = try origin.open("v1"); + + try bucket.put(origin.allocator, try testEntry("https://a.com/x?a=1", "1")); + try bucket.put(origin.allocator, try testEntry("https://a.com/x?a=2", "2")); + try bucket.put(origin.allocator, try testEntry("https://a.com/other", "3")); + try bucket.put(origin.allocator, try testEntry("https://a.com/x", "4")); + try bucket.put(origin.allocator, try testEntry("https://a.com/xy?a=1", "5")); + try bucket.put(origin.allocator, try testEntry("https://a.com/x?a=3#frag", "6")); + + try testing.expectEqual(true, bucket.delete("https://a.com/x?b=1#f", true)); + try testing.expectEqual(2, bucket.entries.count()); + try testing.expectEqual("3", bucket.match("https://a.com/other", false).?.body); + try testing.expectEqual("5", bucket.match("https://a.com/xy", true).?.body); + try testing.expectEqual(false, bucket.delete("https://a.com/x", true)); +} + +fn testEntry(url: []const u8, body: []const u8) !*Entry { + const entry = try Entry.create(testing.allocator, url); + errdefer entry.deinit(); + entry.body = try entry.arena.allocator().dupe(u8, body); + return entry; +} diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 46f3cfea6..af9630b89 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -39,15 +39,35 @@ _exec: *const Execution, _url: []const u8, _buf: std.ArrayList(u8), _response: *Response, -_resolver: js.PromiseResolver.Global, _owns_response: bool, _signal: ?*AbortSignal, _manual_redirect: bool, _no_cors: bool, +_sink: Sink, pub const Input = Request.Input; pub const InitOpts = Request.InitOpts; +const Sink = union(enum) { + promise: js.PromiseResolver.Global, + completion: Completion, +}; + +// For a fetch made by Zig code (Cache.add), in place of fetch()'s promise. +// Once `start` has returned without an error, `callback` fires exactly once. +pub const Completion = struct { + ctx: *anyopaque, + callback: *const fn (ctx: *anyopaque, result: Result) void, + + pub const Result = union(enum) { + // Only valid for the duration of the callback. + done: *Response, + err, + // The owner is being torn down: no JS. + shutdown, + }; +}; + pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promise { const resolver = exec.js.local.?.createPromiseResolver(); @@ -83,6 +103,16 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis else => return err, }; + try submit(request, body, .{ .promise = try resolver.persist() }, exec); + return resolver.promise(); +} + +pub fn start(request: *Request, completion: Completion, exec: *const Execution) !void { + const body = try request.bodyBytes(); + return submit(request, body, .{ .completion = completion }, exec); +} + +fn submit(request: *Request, body: ?[]const u8, sink: Sink, exec: *const Execution) !void { const response = try Response.initPending(exec); errdefer response.deinit(exec.page); @@ -91,7 +121,7 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis ._exec = exec, ._buf = .empty, ._url = try response._arena.dupe(u8, request._url), - ._resolver = try resolver.persist(), + ._sink = sink, ._response = response, ._owns_response = true, ._signal = request._signal, @@ -103,7 +133,7 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis log.debug(.http, "fetch", .{ .url = request._url }); } - const transfer = exec.newRequest(.{ + const transfer = try exec.newRequest(.{ .ctx = fetch, .url = request._url, .method = request._method, @@ -131,10 +161,7 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis .done_callback = httpDoneCallback, .error_callback = httpErrorCallback, .shutdown_callback = httpShutdownCallback, - }) catch { - // OOM-class; nothing was committed and no callback fired. - return resolver.promise(); - }; + }); { errdefer transfer.deinit(); @@ -153,7 +180,6 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis // error from here would also fire the `errdefer response.deinit` above // and double-free the arena. transfer.submit() catch {}; - return resolver.promise(); } fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult { @@ -265,6 +291,15 @@ fn httpDoneCallback(ctx: *anyopaque) !void { .len = self._buf.items.len, }); + const resolver = switch (self._sink) { + .promise => |resolver| resolver, + .completion => |completion| { + self._owns_response = false; + defer response.deinit(self._exec.page); + return completion.callback(completion.ctx, .{ .done = response }); + }, + }; + var ls: js.Local.Scope = undefined; self._exec.js.localScope(&ls); defer ls.deinit(); @@ -272,7 +307,7 @@ fn httpDoneCallback(ctx: *anyopaque) !void { const js_val = try ls.local.zigValueToJs(self._response, .{}); self._owns_response = false; response._arena.report(); - return ls.toLocal(self._resolver).resolve("fetch done", js_val); + return ls.toLocal(resolver).resolve("fetch done", js_val); } fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { @@ -301,23 +336,33 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void { response.deinit(self._exec.page); }; + const resolver = switch (self._sink) { + .promise => |resolver| resolver, + .completion => |completion| return completion.callback(completion.ctx, .err), + }; + var ls: js.Local.Scope = undefined; self._exec.js.localScope(&ls); defer ls.deinit(); // fetch() must reject with a TypeError on network errors per spec - ls.toLocal(self._resolver).rejectError("fetch error", .{ .type_error = "fetch error" }); + ls.toLocal(resolver).rejectError("fetch error", .{ .type_error = "fetch error" }); } fn httpShutdownCallback(ctx: *anyopaque) void { const self: *Fetch = @ptrCast(@alignCast(ctx)); if (self._owns_response) { + const sink = self._sink; var response = self._response; response._http_transfer = null; response.deinit(self._exec.page); // Do not access `self` after this point: the Fetch struct was // allocated from response._arena which has been released. + switch (sink) { + .promise => {}, + .completion => |completion| completion.callback(completion.ctx, .shutdown), + } } } diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index 9cc1a0202..de209e081 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -338,6 +338,15 @@ fn consume(self: *Response, exec: *const Execution) !void { } } +pub fn consumeBytes(self: *Response, exec: *const Execution) ![]const u8 { + try self.consume(exec); + return switch (self._body) { + .empty => "", + .bytes => |b| b, + .stream => exec.js.local.?.typeError("Response with a ReadableStream body is unsupported"), + }; +} + pub fn getText(self: *Response, exec: *const Execution) !js.Promise { return self.consumeAs(.text, exec); }