131 lines
7.1 KiB
JavaScript
131 lines
7.1 KiB
JavaScript
self.addEventListener("install", () => {
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
// Navigations through this worker wait for activation. Awaiting a client
|
|
// reload here deadlocks first visits, including the token sign-in screen.
|
|
event.waitUntil(self.clients.claim());
|
|
});
|
|
|
|
self.addEventListener("fetch", (event) => {
|
|
if (event.request.mode !== "navigate") return;
|
|
event.respondWith(fetch(new Request(event.request, { cache: "no-store" })));
|
|
});
|
|
|
|
// This worker never receives credentials and never performs authenticated fetches.
|
|
// Safari requires a visible notification for each valid message push. Reads are
|
|
// reconciled only by foreground page messages, never an invisible Web Push.
|
|
const PUSH_TITLE = "Sybil Agent", PUSH_BODY = "You have a new message.", PUSH_URL = "/?agent=root";
|
|
const sourceId = value => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
const cursor = value => typeof value === "string" && /^\d{1,16}$/.test(value) && Number.isSafeInteger(Number(value));
|
|
const messageId = value => typeof value === "string" && /^[A-Za-z0-9:_-]{1,200}$/.test(value);
|
|
const owned = value => value?.version === 1 && value.kind === "message" && sourceId(value.sourceId) && cursor(value.cursor) && messageId(value.messageId);
|
|
let queue = Promise.resolve();
|
|
const serial = operation => { const result = queue.then(operation); queue = result.catch(() => undefined); return result; };
|
|
function database() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open("sybil-agent-notifications-v1", 1);
|
|
request.onupgradeneeded = () => request.result.createObjectStore("sources", { keyPath: "sourceId" });
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(new Error("Notification storage unavailable."));
|
|
request.onblocked = () => reject(new Error("Notification storage unavailable."));
|
|
});
|
|
}
|
|
async function stored(id) {
|
|
const db = await database();
|
|
try {
|
|
return await new Promise((resolve, reject) => {
|
|
const request = db.transaction("sources").objectStore("sources").get(id);
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(new Error("Notification storage unavailable."));
|
|
});
|
|
} finally { db.close(); }
|
|
}
|
|
async function save(value) {
|
|
const db = await database();
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
const tx = db.transaction("sources", "readwrite"), store = tx.objectStore("sources");
|
|
store.put({ ...value, touched: Date.now() });
|
|
const request = store.getAll();
|
|
request.onsuccess = () => {
|
|
const rows = request.result.sort((a, b) => b.touched - a.touched);
|
|
for (const stale of rows.slice(16)) store.delete(stale.sourceId);
|
|
};
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(new Error("Notification storage unavailable."));
|
|
tx.onabort = () => reject(new Error("Notification storage unavailable."));
|
|
});
|
|
} finally { db.close(); }
|
|
}
|
|
async function announce(type) {
|
|
for (const client of await self.clients.matchAll({ type: "window", includeUncontrolled: true })) {
|
|
if (new URL(client.url).origin === self.location.origin) client.postMessage({ type });
|
|
}
|
|
}
|
|
self.addEventListener("push", event => {
|
|
event.waitUntil(serial(async () => {
|
|
let value;
|
|
try { const raw = event.data?.text(); if (!raw || raw.length > 4096) return; value = JSON.parse(raw); } catch { return; }
|
|
if (!owned(value) || value.url !== PUSH_URL || value.tag !== `sybil-agent:${value.sourceId}`
|
|
|| value.title !== PUSH_TITLE || value.body !== PUSH_BODY) return;
|
|
const previous = await stored(value.sourceId).catch(() => null);
|
|
// Re-delivery replaces the same owned tag without regressing its cursor.
|
|
// Still show it visibly: local read state is not a reason for an invisible push.
|
|
const identity = owned(previous?.latest) && BigInt(previous.latest.cursor) > BigInt(value.cursor) ? previous.latest : {
|
|
version: 1, kind: "message", sourceId: value.sourceId, messageId: value.messageId, cursor: BigInt(value.cursor).toString(),
|
|
};
|
|
await self.registration.showNotification(PUSH_TITLE, {
|
|
body: PUSH_BODY, tag: `sybil-agent:${identity.sourceId}`, icon: "/icons/icon-192.png", badge: "/icons/icon-192.png",
|
|
renotify: false, data: identity,
|
|
});
|
|
await save({ ...previous, sourceId: identity.sourceId, latest: identity }).catch(() => undefined);
|
|
await announce("sybil-push-shown");
|
|
}));
|
|
});
|
|
self.addEventListener("notificationclick", event => {
|
|
event.notification.close();
|
|
if (!owned(event.notification.data) || event.notification.tag !== `sybil-agent:${event.notification.data.sourceId}`) return;
|
|
event.waitUntil((async () => {
|
|
const destination = new URL(PUSH_URL, self.location.origin).href;
|
|
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
for (const client of windows) {
|
|
const url = new URL(client.url);
|
|
if (url.origin !== self.location.origin || !["/", "/search"].includes(url.pathname)) continue;
|
|
try { const routed = await client.navigate(destination); if (routed) { await routed.focus(); return; } } catch { /* Try another client. */ }
|
|
}
|
|
await self.clients.openWindow(destination);
|
|
// A tap routes only. The rendered transcript owns the read acknowledgment.
|
|
})());
|
|
});
|
|
self.addEventListener("message", event => {
|
|
const value = event.data;
|
|
if (value?.version !== 1 || !["sybil-agent-read", "sybil-agent-logout"].includes(value.type)) return;
|
|
event.waitUntil(serial(async () => {
|
|
// A same-origin WindowClient is required; no remote/message-channel authority.
|
|
const client = event.source?.id ? await self.clients.get(event.source.id) : null;
|
|
if (!client || client.type !== "window" || new URL(client.url).origin !== self.location.origin) return;
|
|
if (value.type === "sybil-agent-read") {
|
|
if (!sourceId(value.sourceId) || !cursor(value.cursor)) return;
|
|
const previous = await stored(value.sourceId).catch(() => null);
|
|
const readCursor = cursor(previous?.readCursor) && BigInt(previous.readCursor) > BigInt(value.cursor) ? previous.readCursor : BigInt(value.cursor).toString();
|
|
await save({ ...previous, sourceId: value.sourceId, readCursor }).catch(() => undefined);
|
|
const notifications = await self.registration.getNotifications({ tag: `sybil-agent:${value.sourceId}` });
|
|
for (const notification of notifications) {
|
|
const data = notification.data;
|
|
if (owned(data) && data.sourceId === value.sourceId && notification.tag === `sybil-agent:${value.sourceId}` && BigInt(data.cursor) <= BigInt(readCursor)) notification.close();
|
|
}
|
|
} else {
|
|
for (const notification of await self.registration.getNotifications()) {
|
|
if (owned(notification.data) && notification.tag === `sybil-agent:${notification.data.sourceId}`) notification.close();
|
|
}
|
|
}
|
|
event.ports?.[0]?.postMessage({ ok: true });
|
|
}));
|
|
});
|
|
self.addEventListener("pushsubscriptionchange", event => {
|
|
// Re-registration needs a currently authenticated page. Never cache its token here.
|
|
event.waitUntil(announce("sybil-push-subscription-changed"));
|
|
});
|