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 11eabdf57..099a27dd0 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,
@@ -321,6 +323,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";
}
@@ -1173,6 +1184,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 2bdef5b11..c00b296c3 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 14fab632d..aefc66c1d 100644
--- a/src/browser/webapi/net/Fetch.zig
+++ b/src/browser/webapi/net/Fetch.zig
@@ -41,15 +41,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();
@@ -85,6 +105,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);
@@ -93,7 +123,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,
@@ -105,7 +135,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,
@@ -133,10 +163,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();
@@ -172,7 +199,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 {
@@ -284,6 +310,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();
@@ -291,7 +326,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 {
@@ -320,23 +355,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);
}