Merge pull request #3562 from lightpanda-io/service-worker-cachestore

WebApi: Add Cache/CacheStore when ServiceWorker is enabled
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-19 07:13:16 +08:00
commit ff3116432d
16 files changed
+1206 -21

No files matched your search

+5 -2
View File
@@ -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();
+27 -8
View File
@@ -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;
}
+4
View File
@@ -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"),
+236
View File
@@ -0,0 +1,236 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=cache_storage type=module>
const state = await testing.async();
testing.expectTrue('caches' in window);
testing.expectTrue(caches instanceof CacheStorage);
testing.expectTrue(caches === window.caches);
await caches.delete('cs-a');
await caches.delete('cs-b');
const before = await caches.has('cs-a');
const a = await caches.open('cs-a');
await caches.open('cs-b');
const after = await caches.has('cs-a');
const names = await caches.keys();
const deleted = await caches.delete('cs-a');
const deleted_again = await caches.delete('cs-a');
const gone = await caches.has('cs-a');
// A Cache outlives its name.
await a.put('/orphan', new Response('orphan'));
const orphan = await (await a.match('/orphan')).text();
const unreachable = await caches.match('/orphan');
state.resolve();
await state.done(() => {
testing.expectEqual(false, before);
testing.expectTrue(a instanceof Cache);
testing.expectEqual(true, after);
// creation order
testing.expectTrue(names.indexOf('cs-a') >= 0);
testing.expectEqual(names.indexOf('cs-a') + 1, names.indexOf('cs-b'));
testing.expectEqual(true, deleted);
testing.expectEqual(false, deleted_again);
testing.expectEqual(false, gone);
testing.expectEqual('orphan', orphan);
testing.expectEqual(undefined, unreachable);
});
</script>
<script id=put_match type=module>
const state = await testing.async();
await caches.delete('put-match');
const cache = await caches.open('put-match');
const miss = await cache.match('/nope');
const stored = new Response('hello', {
status: 201,
statusText: 'Created',
headers: { 'Content-Type': 'text/x-test', 'X-Custom': 'abc' },
});
const put_result = await cache.put('/a?x=1#frag', stored);
const hit = await cache.match(new Request('/a?x=1'));
const hit_text = await hit.text();
// every match is a new Response, with its own body
const hit2 = await cache.match('/a?x=1#other');
const hit2_text = await hit2.text();
const no_search = await cache.match('/a');
const ignore_search = await cache.match('/a?y=2', { ignoreSearch: true });
const post = await cache.match(new Request('/a?x=1', { method: 'POST' }));
// put replaces
await cache.put('/a?x=1', new Response('replaced'));
const replaced = await (await cache.match('/a?x=1')).text();
const keys = await cache.keys();
let immutable = null;
try {
hit.headers.set('x-custom', 'nope');
} catch (e) {
immutable = e;
}
state.resolve();
await state.done(() => {
testing.expectEqual(undefined, miss);
testing.expectEqual(undefined, put_result);
testing.expectEqual(true, stored.bodyUsed);
testing.expectTrue(hit instanceof Response);
testing.expectEqual(201, hit.status);
testing.expectEqual('Created', hit.statusText);
testing.expectEqual('text/x-test', hit.headers.get('content-type'));
testing.expectEqual('abc', hit.headers.get('x-custom'));
testing.expectTrue(immutable instanceof TypeError);
testing.expectEqual('hello', hit_text);
testing.expectTrue(hit !== hit2);
testing.expectEqual('hello', hit2_text);
testing.expectEqual(undefined, no_search);
testing.expectTrue(ignore_search instanceof Response);
testing.expectEqual(undefined, post);
testing.expectEqual('replaced', replaced);
testing.expectEqual(1, keys.length);
testing.expectTrue(keys[0] instanceof Request);
testing.expectEqual('GET', keys[0].method);
testing.expectTrue(keys[0].url.endsWith('/a?x=1'));
});
</script>
<script id=put_rejects type=module>
const state = await testing.async();
await caches.delete('put-rejects');
const cache = await caches.open('put-rejects');
async function rejection(promise) {
try {
await promise;
return null;
} catch (e) {
return e;
}
}
const used = new Response('used');
await used.text();
const errors = [
await rejection(cache.put(new Request('/p', { method: 'POST', body: 'x' }), new Response('x'))),
await rejection(cache.put('data:text/plain,x', new Response('x'))),
await rejection(cache.put('/p', new Response('x', { status: 206 }))),
await rejection(cache.put('/p', new Response('x', { headers: { Vary: 'Accept, *' } }))),
await rejection(cache.put('/p', used)),
];
const vary_ok = await rejection(cache.put('/p', new Response('x', { headers: { Vary: 'Accept' } })));
state.resolve();
await state.done(() => {
testing.expectEqual('TypeError,TypeError,TypeError,TypeError,TypeError', errors.map((e) => e && e.name).join(','));
testing.expectEqual(null, vary_ok);
});
</script>
<script id=delete type=module>
const state = await testing.async();
await caches.delete('delete');
const cache = await caches.open('delete');
await cache.put('/d?x=1', new Response('1'));
await cache.put('/d?x=2', new Response('2'));
await cache.put('/other', new Response('3'));
const miss = await cache.delete('/d');
const one = await cache.delete('/d?x=1');
const after_one = (await cache.keys()).length;
await cache.put('/d?x=1', new Response('1'));
const all = await cache.delete('/d', { ignoreSearch: true });
const keys = await cache.keys();
state.resolve();
await state.done(() => {
testing.expectEqual(false, miss);
testing.expectEqual(true, one);
testing.expectEqual(2, after_one);
testing.expectEqual(true, all);
testing.expectEqual(1, keys.length);
testing.expectTrue(keys[0].url.endsWith('/other'));
});
</script>
<script id=storage_match type=module>
const state = await testing.async();
await caches.delete('sm-1');
await caches.delete('sm-2');
const one = await caches.open('sm-1');
const two = await caches.open('sm-2');
await one.put('/sm', new Response('one'));
await two.put('/sm', new Response('two'));
await two.put('/sm-two', new Response('only two'));
const first = await (await caches.match('/sm')).text();
const named = await (await caches.match('/sm', { cacheName: 'sm-2' })).text();
const any = await (await caches.match('/sm-two')).text();
const wrong_cache = await caches.match('/sm-two', { cacheName: 'sm-1' });
const no_cache = await caches.match('/sm', { cacheName: 'sm-nope' });
state.resolve();
await state.done(() => {
testing.expectEqual('one', first);
testing.expectEqual('two', named);
testing.expectEqual('only two', any);
testing.expectEqual(undefined, wrong_cache);
testing.expectEqual(undefined, no_cache);
});
</script>
<script id=add type=module>
const state = await testing.async();
await caches.delete('add');
const cache = await caches.open('add');
const added = await cache.add('/xhr/json');
const json = await (await cache.match('/xhr/json')).json();
await cache.addAll(['/xhr', new Request('/xhr/json')]);
const count = (await cache.keys()).length;
await cache.addAll([]);
// All or nothing: one 404 and the good one isn't stored either.
let not_ok = null;
try {
await cache.addAll(['/xhr/xml', '/xhr/404']);
} catch (e) {
not_ok = e;
}
const xml = await cache.match('/xhr/xml');
let not_get = null;
try {
await cache.add(new Request('/xhr', { method: 'POST' }));
} catch (e) {
not_get = e;
}
state.resolve();
await state.done(() => {
testing.expectEqual(undefined, added);
testing.expectEqual('9000!!!', json.over);
testing.expectEqual(2, count);
testing.expectTrue(not_ok instanceof TypeError);
testing.expectEqual(undefined, xml);
testing.expectTrue(not_get instanceof TypeError);
});
</script>
+1
View File
@@ -0,0 +1 @@
postMessage({ present: 'caches' in self, constructor: typeof CacheStorage });
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="caches_absent_without_the_flag">
// `caches` rides on --experimental-features serviceworker. Like
// navigator.serviceWorker it is absent rather than null, and only the way in
// is removed.
{
testing.expectFalse('caches' in window);
testing.expectEqual(undefined, window.caches);
testing.expectTrue(typeof CacheStorage === 'function');
testing.expectTrue(typeof Cache === 'function');
}
</script>
<script id="caches_absent_in_a_worker" type=module>
{
const state = await testing.async();
const worker = new Worker('./disabled-worker.js');
const data = await new Promise((resolve) => {
worker.onmessage = (e) => resolve(e.data);
});
state.resolve();
await state.done(() => {
testing.expectEqual(false, data.present);
testing.expectEqual('function', data.constructor);
});
}
</script>
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=add_all_in_flight_at_teardown type=module>
// Never awaited: the page goes away with one fetch done and one still in
// flight. The runner's leak check is the assertion.
const cache = await caches.open('teardown');
cache.addAll(['/xhr', '/xhr/slow']);
testing.expectTrue(true);
</script>
@@ -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']))
);
});
@@ -367,3 +367,27 @@
});
}
</script>
<script id="service_worker_precache" type=module>
// What the worker's install handler stored is what the page reads: one
// store per origin, shared by both realms.
{
const state = await testing.async();
await caches.delete('sw-precache');
const reg = await navigator.serviceWorker.register('./cache-worker.js', { scope: './precache/' });
await workerReached(reg.installing, 'activated');
const cache = await caches.open('sw-precache');
const keys = await cache.keys();
const script = await (await caches.match('./lifecycle-worker.js')).text();
const json = await (await cache.match('/xhr/json')).json();
state.resolve();
await state.done(() => {
testing.expectEqual(2, keys.length);
testing.expectTrue(script.includes("seen.push('install')"));
testing.expectEqual('9000!!!', json.over);
});
}
</script>
+13 -1
View File
@@ -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, .{});
+12
View File
@@ -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, .{});
+371
View File
@@ -0,0 +1,371 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
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, .{});
};
+106
View File
@@ -0,0 +1,106 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
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", .{});
}
+295
View File
@@ -0,0 +1,295 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
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;
}
+55 -10
View File
@@ -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),
}
}
}
+9
View File
@@ -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);
}