feat(05-02): implement pushDispatcher — VAPID send + 410/404 prune
- buildPushBody: dual-format payload (iOS 18.4+ declarative + legacy) - dispatchPush: calls webpush.sendNotification with TTL=300, urgency=normal - Prunes push_subscriptions row on 410/404 from push service (D-11) - Logs transient errors with [pushDispatcher] prefix; never throws to caller - Default import for web-push (CommonJS — Pitfall 7)
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* pushDispatcher — VAPID-signed push send + 410/404 subscription prune.
|
||||
*
|
||||
* Single send path for all push triggers (reminders, list changes, event changes).
|
||||
* Centralises VAPID signing and dead-subscription pruning so individual triggers
|
||||
* never re-implement crypto or expiry handling.
|
||||
*
|
||||
* Threat mitigations (T-05-03, T-05-04, T-05-05):
|
||||
* - Uses web-push library exclusively for VAPID signing (never hand-rolled).
|
||||
* - Catches per-subscription errors; one failed send never aborts a fan-out loop.
|
||||
* - Logs statusCode + err.message only — never logs subscription keys or payload body.
|
||||
*
|
||||
* Design notes:
|
||||
* - Default import for web-push (CommonJS module — Pitfall 7 from RESEARCH.md).
|
||||
* - setVapidDetails is NOT called here — it is called once from index.ts at startup.
|
||||
* - dispatchPush resolves (never throws) so fan-out loops continue after failures.
|
||||
*/
|
||||
|
||||
import webpush from 'web-push'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { pushSubscriptions } from '../db/schema.js'
|
||||
|
||||
/**
|
||||
* Push subscription row shape (subset of schema.pushSubscriptions used by dispatcher).
|
||||
*/
|
||||
export type PushSubscription = {
|
||||
id: number
|
||||
userId: number
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification content passed to dispatchPush and used to build the push body.
|
||||
*/
|
||||
export type NotificationPayload = {
|
||||
title: string
|
||||
body?: string
|
||||
tag?: string
|
||||
navigate?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the dual-format push payload body string.
|
||||
*
|
||||
* Carries both:
|
||||
* 1. iOS 18.4+ declarative web push (web_push:8030 + notification object)
|
||||
* 2. Legacy title/body/tag/data for iOS 16.4–18.3 and Android
|
||||
*
|
||||
* The service worker reads whichever format the browser understands.
|
||||
*/
|
||||
export function buildPushBody(notification: NotificationPayload): string {
|
||||
const { title, body = '', tag, navigate } = notification
|
||||
|
||||
return JSON.stringify({
|
||||
// iOS 18.4+ declarative web push format (WebKit blog 2025-04-14)
|
||||
web_push: 8030,
|
||||
notification: {
|
||||
title,
|
||||
body,
|
||||
navigate,
|
||||
},
|
||||
// Legacy format for iOS 16.4–18.3 and Android
|
||||
title,
|
||||
body,
|
||||
...(tag !== undefined && { tag }),
|
||||
data: {
|
||||
url: navigate,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign and dispatch a single push notification to one subscription.
|
||||
*
|
||||
* On 410 (Gone) or 404 (Not Found) response from the push service:
|
||||
* - The subscription row is deleted from push_subscriptions (D-11 prune).
|
||||
*
|
||||
* On transient errors (5xx, 429, network failures):
|
||||
* - NO delete is performed.
|
||||
* - The error is logged with the [pushDispatcher] prefix.
|
||||
* - The function resolves (never throws to caller).
|
||||
*
|
||||
* On success:
|
||||
* - No action beyond the send.
|
||||
*/
|
||||
export async function dispatchPush(
|
||||
sub: PushSubscription,
|
||||
notification: NotificationPayload,
|
||||
): Promise<void> {
|
||||
const webPushSub = {
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: sub.p256dh,
|
||||
auth: sub.auth,
|
||||
},
|
||||
}
|
||||
|
||||
const body = buildPushBody(notification)
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(webPushSub, body, {
|
||||
TTL: 300,
|
||||
urgency: 'normal',
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const statusCode = (err as { statusCode?: number }).statusCode
|
||||
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
// Dead subscription — prune from DB (D-11)
|
||||
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id))
|
||||
return
|
||||
}
|
||||
|
||||
// Transient error — log and continue; do NOT delete subscription
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error('[pushDispatcher] sendNotification failed:', statusCode, message)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user