+4
.gitignore
+4
.gitignore
+16
README.md
+16
README.md
···
6
6
7
7
---
8
8
MIT
9
+
10
+
## Web Push (server)
11
+
12
+
This 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
+
18
+
When 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
+
20
+
By 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
+
22
+
Notes:
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.
+398
-4
serve.js
+398
-4
serve.js
···
1
1
import db from './db.json' with { type: 'json'}
2
2
import { serveDir } from 'https://deno.land/std/http/file_server.ts'
3
3
import { apds } from './apds.js'
4
+
import { encode, decode } from './lib/base64.js'
4
5
5
6
await apds.start('apdsv1')
6
7
8
+
const encoder = new TextEncoder()
9
+
10
+
const subscriptionsPath = new URL('./subscriptions.json', import.meta.url)
11
+
const subscriptionsTmpPath = new URL('./subscriptions.json.tmp', import.meta.url)
12
+
13
+
const vapidPath = new URL('./vapid.json', import.meta.url)
14
+
const vapidTmpPath = new URL('./vapid.json.tmp', import.meta.url)
15
+
16
+
const toBase64Url = (b64) =>
17
+
b64.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
18
+
19
+
const fromBase64Url = (b64url) => {
20
+
const b64 = b64url.replaceAll('-', '+').replaceAll('_', '/')
21
+
const pad = '='.repeat((4 - (b64.length % 4)) % 4)
22
+
return b64 + pad
23
+
}
24
+
25
+
const base64UrlEncodeBytes = (bytes) => toBase64Url(encode(bytes))
26
+
const base64UrlDecodeBytes = (b64url) => decode(fromBase64Url(b64url))
27
+
28
+
const 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
+
35
+
const 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
+
43
+
const 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
+
55
+
const 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
+
61
+
const 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
+
75
+
const 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
+
81
+
const 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
+
7
100
if (!await apds.pubkey()) {
8
101
const keypair = await apds.generate()
9
102
await apds.put('keypair', keypair)
···
12
105
console.log('apdsbot started, your pubkey: ' + await apds.pubkey())
13
106
}
14
107
108
+
const vapid = await ensureVapid()
109
+
console.log('Web Push VAPID public key: ' + vapid.publicKey)
110
+
111
+
let pendingPush = false
112
+
let nextAllowedPushAt = 0
113
+
let flushTimer = undefined
114
+
const queuedPushSigs = new Set()
115
+
116
+
const 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
+
143
+
const scheduleFlush = (delayMs = 0) => {
144
+
if (flushTimer !== undefined) return
145
+
flushTimer = setTimeout(() => {
146
+
flushTimer = undefined
147
+
void flushPushQueue()
148
+
}, delayMs)
149
+
}
150
+
151
+
const 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
+
171
+
const queuePushSig = (sig) => {
172
+
if (typeof sig !== 'string' || sig.length === 0) return
173
+
queuedPushSigs.add(sig)
174
+
scheduleFlush(200)
175
+
}
176
+
177
+
const 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
+
188
+
const 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
+
200
+
const hkdfExtract = async (salt, ikm) => await hmacSha256(salt, ikm)
201
+
202
+
const 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
+
215
+
const 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
+
228
+
const 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
+
254
+
const 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
+
277
+
const 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
+
326
+
const 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
+
15
350
const apdsbot = async (ws) => {
16
351
ws.onopen = async () => {
17
352
setTimeout(async () => {
···
42
377
}
43
378
if (m.data.length != 44) {
44
379
await apds.make(m.data)
45
-
await apds.add(m.data)
380
+
const added = await apds.add(m.data)
46
381
const opened = await apds.open(m.data)
47
382
if (opened) {
48
383
const content = await apds.get(opened.substring(13))
···
58
393
console.log('no previous')
59
394
ws.send(yaml.previous)
60
395
}
396
+
}
397
+
if (added) {
398
+
queuePushSig(m.data)
61
399
}
62
400
}
63
401
}
···
68
406
69
407
const directory = async (r) => {
70
408
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
+
71
467
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()
75
469
const q = await apds.query(key)
76
470
if (db[key]) {
77
471
const ar = db[key]