mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-09 12:51:45 -04:00
WebApi: Add Cache/CacheStore when ServiceWorker is enabled
This adds the Cache and CacheStore APIs when ServiceWorker is enabled. Purely in-memory, tied to the Session's lifetime. These generally don't do unbounded growth, but simply load a fixed set of resources. In firefox, when ServiceWorker is disabled, the cache is still available on the Window. I tied it to the existing --experimental-features serviceworker flag (at least for now).
This commit is contained in:
16 files changed
+1206
-21
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Vendored
+236
@@ -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
@@ -0,0 +1 @@
|
||||
postMessage({ present: 'caches' in self, constructor: typeof CacheStorage });
|
||||
+30
@@ -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
@@ -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>
|
||||
@@ -41,9 +41,10 @@ const ErrorEvent = @import("event/ErrorEvent.zig");
|
||||
const MessageEvent = @import("event/MessageEvent.zig");
|
||||
const MessagePort = @import("MessagePort.zig");
|
||||
const MediaQueryList = @import("css/MediaQueryList.zig");
|
||||
const storage = @import("storage/storage.zig");
|
||||
const idb = @import("storage/idb/idb.zig");
|
||||
const storage = @import("storage/storage.zig");
|
||||
const CookieStore = @import("storage/CookieStore.zig");
|
||||
const CacheStorage = @import("cache/CacheStorage.zig");
|
||||
const Element = @import("Element.zig");
|
||||
const CSSStyleProperties = @import("css/CSSStyleProperties.zig");
|
||||
const CustomElementRegistry = @import("CustomElementRegistry.zig");
|
||||
@@ -77,6 +78,7 @@ _visual_viewport: *VisualViewport,
|
||||
_performance: *Performance,
|
||||
_cookie_store: ?*CookieStore = null,
|
||||
_idb_factory: ?*idb.IDBFactory = null,
|
||||
_caches: ?*CacheStorage = null,
|
||||
_on_load: ?js.Function.Global = null,
|
||||
_on_pageshow: ?js.Function.Global = null,
|
||||
_on_popstate: ?js.Function.Global = null,
|
||||
@@ -317,6 +319,15 @@ fn getIndexedDB(self: *Window, exec: *Execution) !*idb.IDBFactory {
|
||||
return f;
|
||||
}
|
||||
|
||||
fn getCaches(self: *Window, exec: *Execution) !*CacheStorage {
|
||||
if (self._caches) |c| {
|
||||
return c;
|
||||
}
|
||||
const c = try exec._factory.create(CacheStorage{});
|
||||
self._caches = c;
|
||||
return c;
|
||||
}
|
||||
|
||||
pub fn getOrigin(self: *const Window) []const u8 {
|
||||
return self._frame.origin orelse "null";
|
||||
}
|
||||
@@ -1165,6 +1176,7 @@ pub const JsApi = struct {
|
||||
pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{});
|
||||
pub const cookieStore = bridge.accessor(Window.getCookieStore, null, .{});
|
||||
pub const indexedDB = bridge.accessor(Window.getIndexedDB, null, .{});
|
||||
pub const caches = bridge.accessor(Window.getCaches, null, .{});
|
||||
pub const origin = bridge.accessor(Window.getOrigin, Window.setOrigin, .{});
|
||||
pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{ .deletable = false });
|
||||
pub const history = bridge.accessor(Window.getHistory, null, .{});
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
Vendored
+371
@@ -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
@@ -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", .{});
|
||||
}
|
||||
Vendored
+295
@@ -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;
|
||||
}
|
||||
@@ -39,15 +39,35 @@ _exec: *const Execution,
|
||||
_url: []const u8,
|
||||
_buf: std.ArrayList(u8),
|
||||
_response: *Response,
|
||||
_resolver: js.PromiseResolver.Global,
|
||||
_owns_response: bool,
|
||||
_signal: ?*AbortSignal,
|
||||
_manual_redirect: bool,
|
||||
_no_cors: bool,
|
||||
_sink: Sink,
|
||||
|
||||
pub const Input = Request.Input;
|
||||
pub const InitOpts = Request.InitOpts;
|
||||
|
||||
const Sink = union(enum) {
|
||||
promise: js.PromiseResolver.Global,
|
||||
completion: Completion,
|
||||
};
|
||||
|
||||
// For a fetch made by Zig code (Cache.add), in place of fetch()'s promise.
|
||||
// Once `start` has returned without an error, `callback` fires exactly once.
|
||||
pub const Completion = struct {
|
||||
ctx: *anyopaque,
|
||||
callback: *const fn (ctx: *anyopaque, result: Result) void,
|
||||
|
||||
pub const Result = union(enum) {
|
||||
// Only valid for the duration of the callback.
|
||||
done: *Response,
|
||||
err,
|
||||
// The owner is being torn down: no JS.
|
||||
shutdown,
|
||||
};
|
||||
};
|
||||
|
||||
pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promise {
|
||||
const resolver = exec.js.local.?.createPromiseResolver();
|
||||
|
||||
@@ -83,6 +103,16 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis
|
||||
else => return err,
|
||||
};
|
||||
|
||||
try submit(request, body, .{ .promise = try resolver.persist() }, exec);
|
||||
return resolver.promise();
|
||||
}
|
||||
|
||||
pub fn start(request: *Request, completion: Completion, exec: *const Execution) !void {
|
||||
const body = try request.bodyBytes();
|
||||
return submit(request, body, .{ .completion = completion }, exec);
|
||||
}
|
||||
|
||||
fn submit(request: *Request, body: ?[]const u8, sink: Sink, exec: *const Execution) !void {
|
||||
const response = try Response.initPending(exec);
|
||||
errdefer response.deinit(exec.page);
|
||||
|
||||
@@ -91,7 +121,7 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis
|
||||
._exec = exec,
|
||||
._buf = .empty,
|
||||
._url = try response._arena.dupe(u8, request._url),
|
||||
._resolver = try resolver.persist(),
|
||||
._sink = sink,
|
||||
._response = response,
|
||||
._owns_response = true,
|
||||
._signal = request._signal,
|
||||
@@ -103,7 +133,7 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis
|
||||
log.debug(.http, "fetch", .{ .url = request._url });
|
||||
}
|
||||
|
||||
const transfer = exec.newRequest(.{
|
||||
const transfer = try exec.newRequest(.{
|
||||
.ctx = fetch,
|
||||
.url = request._url,
|
||||
.method = request._method,
|
||||
@@ -131,10 +161,7 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis
|
||||
.done_callback = httpDoneCallback,
|
||||
.error_callback = httpErrorCallback,
|
||||
.shutdown_callback = httpShutdownCallback,
|
||||
}) catch {
|
||||
// OOM-class; nothing was committed and no callback fired.
|
||||
return resolver.promise();
|
||||
};
|
||||
});
|
||||
|
||||
{
|
||||
errdefer transfer.deinit();
|
||||
@@ -153,7 +180,6 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis
|
||||
// error from here would also fire the `errdefer response.deinit` above
|
||||
// and double-free the arena.
|
||||
transfer.submit() catch {};
|
||||
return resolver.promise();
|
||||
}
|
||||
|
||||
fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult {
|
||||
@@ -265,6 +291,15 @@ fn httpDoneCallback(ctx: *anyopaque) !void {
|
||||
.len = self._buf.items.len,
|
||||
});
|
||||
|
||||
const resolver = switch (self._sink) {
|
||||
.promise => |resolver| resolver,
|
||||
.completion => |completion| {
|
||||
self._owns_response = false;
|
||||
defer response.deinit(self._exec.page);
|
||||
return completion.callback(completion.ctx, .{ .done = response });
|
||||
},
|
||||
};
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
self._exec.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
@@ -272,7 +307,7 @@ fn httpDoneCallback(ctx: *anyopaque) !void {
|
||||
const js_val = try ls.local.zigValueToJs(self._response, .{});
|
||||
self._owns_response = false;
|
||||
response._arena.report();
|
||||
return ls.toLocal(self._resolver).resolve("fetch done", js_val);
|
||||
return ls.toLocal(resolver).resolve("fetch done", js_val);
|
||||
}
|
||||
|
||||
fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
|
||||
@@ -301,23 +336,33 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
|
||||
response.deinit(self._exec.page);
|
||||
};
|
||||
|
||||
const resolver = switch (self._sink) {
|
||||
.promise => |resolver| resolver,
|
||||
.completion => |completion| return completion.callback(completion.ctx, .err),
|
||||
};
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
self._exec.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
// fetch() must reject with a TypeError on network errors per spec
|
||||
ls.toLocal(self._resolver).rejectError("fetch error", .{ .type_error = "fetch error" });
|
||||
ls.toLocal(resolver).rejectError("fetch error", .{ .type_error = "fetch error" });
|
||||
}
|
||||
|
||||
fn httpShutdownCallback(ctx: *anyopaque) void {
|
||||
const self: *Fetch = @ptrCast(@alignCast(ctx));
|
||||
|
||||
if (self._owns_response) {
|
||||
const sink = self._sink;
|
||||
var response = self._response;
|
||||
response._http_transfer = null;
|
||||
response.deinit(self._exec.page);
|
||||
// Do not access `self` after this point: the Fetch struct was
|
||||
// allocated from response._arena which has been released.
|
||||
switch (sink) {
|
||||
.promise => {},
|
||||
.completion => |completion| completion.callback(completion.ctx, .shutdown),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user