feat(agent): add opt-in push notifications
This commit is contained in:
@@ -12,3 +12,119 @@ 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"));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user