OverviewFilesCommitsIssuesDocsReleasesPeers

apds commits / 0c99e26

feat: add web push subscriptions and VAPID support
0c99e26e9629d5622a7ef3c8f9315a73e19c49e3
by Everett Bogue <ev@evbogue.com> · 2025-12-13T18:36:59.000Z

Changed files (3) +418 -4

.gitignore +4 -0
……@@ -0,0 +1,4 @@
1subscriptions.json
2subscriptions.json.tmp
3vapid.json
4vapid.json.tmp
README.md +16 -0
……@@ -6,3 +6,19 @@ Try it at https://apds.anproto.com/
66
77---
88MIT
9
10## Web Push (server)
11
12This server exposes minimal Web Push subscription + notification plumbing:
13
14- `GET /push/vapidPublicKey` returns `{ publicKey }` (base64url) for `pushManager.subscribe({ applicationServerKey })`.
15- `POST /push/subscribe` stores a browser PushSubscription (JSON body).
16- `POST /push/test` triggers a test push to all stored subscriptions.
17
18When a *new* ANProto message is added via WebSocket, the server sends a push with payload `{ type: "latest" }` so the service worker can wake up and fetch `/latest`.
19
20By default it sends `{ type: "anproto", sigs: ["...==", ...] }` (batched + throttled), so the service worker can process specific messages and/or still fetch `/latest` as a fallback.
21
22Notes:
23- The server writes `subscriptions.json` and `vapid.json` locally.
24- Set `VAPID_SUBJECT` (e.g. `mailto:you@example.com` or `https://your.site`) if you don’t want the default.
serve.js +398 -4
……@@ -1,9 +1,102 @@
11import db from './db.json' with { type: 'json'}
22import { serveDir } from 'https://deno.land/std/http/file_server.ts'
33import { apds } from './apds.js'
4import { encode, decode } from './lib/base64.js'
45
56await apds.start('apdsv1')
67
8const encoder = new TextEncoder()
9
10const subscriptionsPath = new URL('./subscriptions.json', import.meta.url)
11const subscriptionsTmpPath = new URL('./subscriptions.json.tmp', import.meta.url)
12
13const vapidPath = new URL('./vapid.json', import.meta.url)
14const vapidTmpPath = new URL('./vapid.json.tmp', import.meta.url)
15
16const toBase64Url = (b64) =>
17 b64.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
18
19const fromBase64Url = (b64url) => {
20 const b64 = b64url.replaceAll('-', '+').replaceAll('_', '/')
21 const pad = '='.repeat((4 - (b64.length % 4)) % 4)
22 return b64 + pad
23}
24
25const base64UrlEncodeBytes = (bytes) => toBase64Url(encode(bytes))
26const base64UrlDecodeBytes = (b64url) => decode(fromBase64Url(b64url))
27
28const jsonHeaders = () => {
29 const header = new Headers()
30 header.append("Content-Type", "application/json")
31 header.append("Access-Control-Allow-Origin", "*")
32 return header
33}
34
35const corsPreflight = () => {
36 const header = jsonHeaders()
37 header.append('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
38 header.append('Access-Control-Allow-Headers', 'Content-Type')
39 header.append('Access-Control-Max-Age', '86400')
40 return new Response(null, { status: 204, headers: header })
41}
42
43const readSubscriptions = async () => {
44 try {
45 const txt = await Deno.readTextFile(subscriptionsPath)
46 const parsed = JSON.parse(txt)
47 if (!parsed || typeof parsed !== 'object') return { subscriptions: {} }
48 if (!parsed.subscriptions || typeof parsed.subscriptions !== 'object') return { subscriptions: {} }
49 return parsed
50 } catch {
51 return { subscriptions: {} }
52 }
53}
54
55const writeSubscriptions = async (data) => {
56 const payload = JSON.stringify(data, null, 2)
57 await Deno.writeTextFile(subscriptionsTmpPath, payload)
58 await Deno.rename(subscriptionsTmpPath, subscriptionsPath)
59}
60
61const readVapid = async () => {
62 try {
63 const txt = await Deno.readTextFile(vapidPath)
64 const parsed = JSON.parse(txt)
65 if (!parsed || typeof parsed !== 'object') return undefined
66 if (typeof parsed.publicKey !== 'string') return undefined
67 if (!parsed.privateJwk || typeof parsed.privateJwk !== 'object') return undefined
68 if (typeof parsed.subject !== 'string') return undefined
69 return parsed
70 } catch {
71 return undefined
72 }
73}
74
75const writeVapid = async (data) => {
76 const payload = JSON.stringify(data, null, 2)
77 await Deno.writeTextFile(vapidTmpPath, payload)
78 await Deno.rename(vapidTmpPath, vapidPath)
79}
80
81const ensureVapid = async () => {
82 const existing = await readVapid()
83 if (existing) return existing
84
85 const subject = Deno.env.get('VAPID_SUBJECT') || 'mailto:admin@localhost'
86 const keypair = await crypto.subtle.generateKey(
87 { name: 'ECDSA', namedCurve: 'P-256' },
88 true,
89 ['sign', 'verify']
90 )
91 const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', keypair.publicKey))
92 const publicKey = base64UrlEncodeBytes(publicRaw)
93 const privateJwk = await crypto.subtle.exportKey('jwk', keypair.privateKey)
94
95 const data = { subject, publicKey, privateJwk }
96 await writeVapid(data)
97 return data
98}
99
7100if (!await apds.pubkey()) {
8101 const keypair = await apds.generate()
9102 await apds.put('keypair', keypair)
……@@ -12,6 +105,248 @@ if (!await apds.pubkey()) {
12105 console.log('apdsbot started, your pubkey: ' + await apds.pubkey())
13106}
14107
108const vapid = await ensureVapid()
109console.log('Web Push VAPID public key: ' + vapid.publicKey)
110
111let pendingPush = false
112let nextAllowedPushAt = 0
113let flushTimer = undefined
114const queuedPushSigs = new Set()
115
116const broadcastPush = async (payloadObj) => {
117 const store = await readSubscriptions()
118 const endpoints = Object.keys(store.subscriptions ?? {})
119 if (endpoints.length === 0) return
120
121 const dead = new Set()
122 const results = await Promise.allSettled(
123 endpoints.map(async (endpoint) => {
124 const entry = store.subscriptions[endpoint]
125 if (!entry?.subscription) return
126 try {
127 const res = await sendWebPush(entry.subscription, payloadObj)
128 if (res.status === 404 || res.status === 410) dead.add(endpoint)
129 } catch {
130 // Best-effort.
131 }
132 })
133 )
134 void results
135
136 if (dead.size > 0) {
137 for (const endpoint of dead) delete store.subscriptions[endpoint]
138 store.updatedAt = Date.now()
139 await writeSubscriptions(store)
140 }
141}
142
143const scheduleFlush = (delayMs = 0) => {
144 if (flushTimer !== undefined) return
145 flushTimer = setTimeout(() => {
146 flushTimer = undefined
147 void flushPushQueue()
148 }, delayMs)
149}
150
151const flushPushQueue = async () => {
152 if (pendingPush) return scheduleFlush(500)
153
154 const now = Date.now()
155 if (now < nextAllowedPushAt) return scheduleFlush(nextAllowedPushAt - now)
156
157 const sigs = Array.from(queuedPushSigs).slice(0, 25)
158 if (sigs.length === 0) return
159 for (const sig of sigs) queuedPushSigs.delete(sig)
160
161 pendingPush = true
162 nextAllowedPushAt = now + 2000
163 try {
164 await broadcastPush({ type: 'anproto', sigs, ts: now })
165 } finally {
166 pendingPush = false
167 if (queuedPushSigs.size > 0) scheduleFlush(0)
168 }
169}
170
171const queuePushSig = (sig) => {
172 if (typeof sig !== 'string' || sig.length === 0) return
173 queuedPushSigs.add(sig)
174 scheduleFlush(200)
175}
176
177const concatBytes = (...parts) => {
178 const total = parts.reduce((sum, p) => sum + p.length, 0)
179 const out = new Uint8Array(total)
180 let offset = 0
181 for (const p of parts) {
182 out.set(p, offset)
183 offset += p.length
184 }
185 return out
186}
187
188const hmacSha256 = async (keyBytes, dataBytes) => {
189 const key = await crypto.subtle.importKey(
190 'raw',
191 keyBytes,
192 { name: 'HMAC', hash: 'SHA-256' },
193 false,
194 ['sign']
195 )
196 const sig = await crypto.subtle.sign('HMAC', key, dataBytes)
197 return new Uint8Array(sig)
198}
199
200const hkdfExtract = async (salt, ikm) => await hmacSha256(salt, ikm)
201
202const hkdfExpand = async (prk, info, length) => {
203 const hashLen = 32
204 const n = Math.ceil(length / hashLen)
205 let t = new Uint8Array(0)
206 let okm = new Uint8Array(0)
207 for (let i = 1; i <= n; i++) {
208 const input = concatBytes(t, info, new Uint8Array([i]))
209 t = await hmacSha256(prk, input)
210 okm = concatBytes(okm, t)
211 }
212 return okm.slice(0, length)
213}
214
215const readDerLength = (bytes, offset) => {
216 let length = bytes[offset]
217 if ((length & 0x80) === 0) {
218 return { length, read: 1 }
219 }
220 const numBytes = length & 0x7f
221 length = 0
222 for (let i = 0; i < numBytes; i++) {
223 length = (length << 8) | bytes[offset + 1 + i]
224 }
225 return { length, read: 1 + numBytes }
226}
227
228const ecdsaDerToJose = (derSignature, size = 32) => {
229 const bytes = derSignature instanceof Uint8Array ? derSignature : new Uint8Array(derSignature)
230 let offset = 0
231 if (bytes[offset++] !== 0x30) throw new Error('Invalid DER signature (no sequence)')
232 const seqLen = readDerLength(bytes, offset)
233 offset += seqLen.read
234 if (bytes[offset++] !== 0x02) throw new Error('Invalid DER signature (no r)')
235 const rLen = readDerLength(bytes, offset)
236 offset += rLen.read
237 let r = bytes.slice(offset, offset + rLen.length)
238 offset += rLen.length
239 if (bytes[offset++] !== 0x02) throw new Error('Invalid DER signature (no s)')
240 const sLen = readDerLength(bytes, offset)
241 offset += sLen.read
242 let s = bytes.slice(offset, offset + sLen.length)
243
244 while (r.length > 0 && r[0] === 0x00 && r.length > size) r = r.slice(1)
245 while (s.length > 0 && s[0] === 0x00 && s.length > size) s = s.slice(1)
246 if (r.length > size || s.length > size) throw new Error('Invalid DER signature (r/s too large)')
247
248 const out = new Uint8Array(size * 2)
249 out.set(r, size - r.length)
250 out.set(s, size * 2 - s.length)
251 return out
252}
253
254const createVapidJwt = async (endpoint) => {
255 const url = new URL(endpoint)
256 const aud = `${url.protocol}//${url.host}`
257 const exp = Math.floor(Date.now() / 1000) + (12 * 60 * 60)
258 const header = base64UrlEncodeBytes(encoder.encode(JSON.stringify({ typ: 'JWT', alg: 'ES256' })))
259 const payload = base64UrlEncodeBytes(encoder.encode(JSON.stringify({ aud, exp, sub: vapid.subject })))
260 const data = `${header}.${payload}`
261
262 const privateKey = await crypto.subtle.importKey(
263 'jwk',
264 vapid.privateJwk,
265 { name: 'ECDSA', namedCurve: 'P-256' },
266 false,
267 ['sign']
268 )
269 const derSig = new Uint8Array(
270 await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, encoder.encode(data))
271 )
272 const joseSig = ecdsaDerToJose(derSig)
273 const signature = base64UrlEncodeBytes(joseSig)
274 return `${data}.${signature}`
275}
276
277const encryptWebPushPayload = async (subscription, payloadBytes) => {
278 const receiverPublicKey = base64UrlDecodeBytes(subscription.keys.p256dh)
279 const authSecret = base64UrlDecodeBytes(subscription.keys.auth)
280
281 const ephemeral = await crypto.subtle.generateKey(
282 { name: 'ECDH', namedCurve: 'P-256' },
283 true,
284 ['deriveBits']
285 )
286 const senderPublicKey = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey))
287
288 const receiverKey = await crypto.subtle.importKey(
289 'raw',
290 receiverPublicKey,
291 { name: 'ECDH', namedCurve: 'P-256' },
292 false,
293 []
294 )
295 const sharedSecret = new Uint8Array(
296 await crypto.subtle.deriveBits({ name: 'ECDH', public: receiverKey }, ephemeral.privateKey, 256)
297 )
298
299 const prk = await hkdfExtract(authSecret, sharedSecret)
300 const authInfo = encoder.encode('Content-Encoding: auth\u0000')
301 const ikm = await hkdfExpand(prk, authInfo, 32)
302
303 const salt = crypto.getRandomValues(new Uint8Array(16))
304 const prk2 = await hkdfExtract(salt, ikm)
305
306 const context = concatBytes(
307 new Uint8Array([0x00, 0x41]),
308 receiverPublicKey,
309 new Uint8Array([0x00, 0x41]),
310 senderPublicKey
311 )
312
313 const cekInfo = concatBytes(encoder.encode('Content-Encoding: aes128gcm\u0000'), context)
314 const nonceInfo = concatBytes(encoder.encode('Content-Encoding: nonce\u0000'), context)
315 const cek = await hkdfExpand(prk2, cekInfo, 16)
316 const nonce = await hkdfExpand(prk2, nonceInfo, 12)
317
318 const key = await crypto.subtle.importKey('raw', cek, { name: 'AES-GCM' }, false, ['encrypt'])
319 const padding = new Uint8Array([0x00, 0x00])
320 const plaintext = concatBytes(padding, payloadBytes)
321 const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, plaintext))
322
323 return { salt, senderPublicKey, ciphertext }
324}
325
326const sendWebPush = async (subscription, payloadObj) => {
327 const payloadBytes = encoder.encode(JSON.stringify(payloadObj))
328 const { salt, senderPublicKey, ciphertext } = await encryptWebPushPayload(subscription, payloadBytes)
329 const jwt = await createVapidJwt(subscription.endpoint)
330
331 const header = new Headers()
332 header.set('TTL', '300')
333 header.set('Content-Type', 'application/octet-stream')
334 header.set('Content-Encoding', 'aes128gcm')
335 header.set('Encryption', `salt=${base64UrlEncodeBytes(salt)}`)
336 header.set(
337 'Crypto-Key',
338 `dh=${base64UrlEncodeBytes(senderPublicKey)}; p256ecdsa=${vapid.publicKey}`
339 )
340 header.set('Authorization', `vapid t=${jwt}, k=${vapid.publicKey}`)
341
342 const res = await fetch(subscription.endpoint, {
343 method: 'POST',
344 headers: header,
345 body: ciphertext
346 })
347 return res
348}
349
15350const apdsbot = async (ws) => {
16351 ws.onopen = async () => {
17352 setTimeout(async () => {
……@@ -42,7 +377,7 @@ const apdsbot = async (ws) => {
42377 }
43378 if (m.data.length != 44) {
44379 await apds.make(m.data)
45 await apds.add(m.data)
380 const added = await apds.add(m.data)
46381 const opened = await apds.open(m.data)
47382 if (opened) {
48383 const content = await apds.get(opened.substring(13))
……@@ -59,6 +394,9 @@ const apdsbot = async (ws) => {
59394 ws.send(yaml.previous)
60395 }
61396 }
397 if (added) {
398 queuePushSig(m.data)
399 }
62400 }
63401 }
64402 ws.onclose = () => {
……@@ -68,10 +406,66 @@ const apdsbot = async (ws) => {
68406
69407const directory = async (r) => {
70408 const url = new URL(r.url)
409 if (r.method === 'OPTIONS') {
410 return corsPreflight()
411 }
412
413 if (url.pathname === '/push/subscribe') {
414 const header = jsonHeaders()
415 if (r.method !== 'POST') {
416 return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: header })
417 }
418
419 let body
420 try {
421 body = await r.json()
422 } catch {
423 return new Response(JSON.stringify({ error: 'Invalid JSON' }), { status: 400, headers: header })
424 }
425
426 const subscription = body?.subscription ?? body
427 const endpoint = subscription?.endpoint
428 const p256dh = subscription?.keys?.p256dh
429 const auth = subscription?.keys?.auth
430
431 if (!endpoint || typeof endpoint !== 'string') {
432 return new Response(JSON.stringify({ error: 'Missing subscription.endpoint' }), { status: 400, headers: header })
433 }
434 if (!p256dh || !auth) {
435 return new Response(JSON.stringify({ error: 'Missing subscription.keys' }), { status: 400, headers: header })
436 }
437
438 const store = await readSubscriptions()
439 store.subscriptions ??= {}
440 store.subscriptions[endpoint] = {
441 subscription,
442 updatedAt: Date.now()
443 }
444 store.updatedAt = Date.now()
445 await writeSubscriptions(store)
446
447 return new Response(JSON.stringify({ ok: true }), { status: 201, headers: header })
448 }
449
450 if (url.pathname === '/push/vapidPublicKey') {
451 const header = jsonHeaders()
452 if (r.method !== 'GET') {
453 return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: header })
454 }
455 return new Response(JSON.stringify({ publicKey: vapid.publicKey }), { status: 200, headers: header })
456 }
457
458 if (url.pathname === '/push/test') {
459 const header = jsonHeaders()
460 if (r.method !== 'POST') {
461 return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: header })
462 }
463 await broadcastPush({ type: 'test', ts: Date.now() })
464 return new Response(JSON.stringify({ ok: true }), { status: 200, headers: header })
465 }
466
71467 const key = url.pathname.substring(1)
72 const header = new Headers()
73 header.append("Content-Type", "application/json")
74 header.append("Access-Control-Allow-Origin", "*")
468 const header = jsonHeaders()
75469 const q = await apds.query(key)
76470 if (db[key]) {
77471 const ar = db[key]