a demonstration replicated social networking web app built with anproto
wiredove.net/
social
ed25519
protocols
1import webpush from 'npm:web-push@3.6.7'
2import { apds } from 'https://esm.sh/gh/evbogue/apds@d9326cb/apds.js'
3
4const DEFAULTS = {
5 dataDir: './data',
6 subsFile: './data/subscriptions.json',
7 stateFile: './data/state.json',
8 configFile: './config.json',
9 vapidSubject: 'mailto:ops@wiredove.net',
10 pushIconUrl: 'https://wiredove.net/dovepurple_sm.png',
11 feedRowsUpstream: 'https://pub.wiredove.net',
12}
13
14async function readJsonFile(path, fallback) {
15 try {
16 const raw = await Deno.readTextFile(path)
17 return JSON.parse(raw)
18 } catch {
19 return fallback
20 }
21}
22
23async function writeJsonFile(path, value) {
24 const raw = JSON.stringify(value, null, 2)
25 await Deno.writeTextFile(path, raw)
26}
27
28async function ensureVapidConfig(configPath, subject) {
29 const fallback = {
30 vapidPublicKey: '',
31 vapidPrivateKey: '',
32 vapidSubject: subject,
33 }
34 const config = await readJsonFile(configPath, fallback)
35
36 if (!config.vapidPublicKey || !config.vapidPrivateKey) {
37 const keys = webpush.generateVAPIDKeys()
38 const nextConfig = {
39 vapidPublicKey: keys.publicKey,
40 vapidPrivateKey: keys.privateKey,
41 vapidSubject: config.vapidSubject || subject,
42 }
43 await writeJsonFile(configPath, nextConfig)
44 return nextConfig
45 }
46
47 if (!config.vapidSubject) {
48 config.vapidSubject = subject
49 await writeJsonFile(configPath, config)
50 }
51
52 return config
53}
54
55function subscriptionId(endpoint) {
56 return btoa(endpoint).replaceAll('=', '')
57}
58
59async function parsePostText(text) {
60 if (!text || typeof text !== 'string') return {}
61
62 const raw = text.trim()
63 let yamlBlock = ''
64 let bodyText = ''
65
66 if (raw.startsWith('---')) {
67 const lines = raw.split('\n')
68 const endIndex = lines.indexOf('---', 1)
69 if (endIndex !== -1) {
70 yamlBlock = lines.slice(1, endIndex).join('\n')
71 bodyText = lines.slice(endIndex + 1).join('\n')
72 }
73 }
74
75 let name
76 let yamlBody
77 try {
78 const parsed = await apds.parseYaml(raw)
79 if (parsed && typeof parsed === 'object') {
80 name = typeof parsed.name === 'string' ? parsed.name.trim() : undefined
81 yamlBody = typeof parsed.body === 'string' ? parsed.body.trim() : undefined
82 }
83 } catch {
84 if (yamlBlock) {
85 try {
86 const parsed = await apds.parseYaml(yamlBlock)
87 if (parsed && typeof parsed === 'object') {
88 name = typeof parsed.name === 'string' ? parsed.name.trim() : undefined
89 yamlBody = typeof parsed.body === 'string' ? parsed.body.trim() : undefined
90 }
91 } catch {
92 // Fall back to raw body if YAML parsing fails.
93 }
94 }
95 }
96
97 const body = bodyText.trim() || (yamlBody || '').trim()
98
99 return {
100 name: name || undefined,
101 body: body || undefined,
102 }
103}
104
105function formatPushTitle(name, author) {
106 const authorLabel = name || (author ? author.substring(0, 10) : 'Someone')
107 return authorLabel
108}
109
110function formatPushBody(body) {
111 if (body && body.trim()) return body.trim()
112 return 'Tap to view the latest update'
113}
114
115function parseOpenedTimestamp(opened) {
116 if (!opened || typeof opened !== 'string' || opened.length < 13) return 0
117 const ts = Number.parseInt(opened.substring(0, 13), 10)
118 return Number.isFinite(ts) ? ts : 0
119}
120
121function summarizeText(text, maxLen = 140) {
122 if (!text || typeof text !== 'string') return ''
123 const single = text.replace(/\s+/g, ' ').trim()
124 if (single.length <= maxLen) return single
125 return single.substring(0, maxLen) + '...'
126}
127
128async function extractFeedRows(messages, limit = 40) {
129 if (!Array.isArray(messages) || !messages.length) return []
130 const contentByHash = new Map()
131 const rowsByHash = new Map()
132 const replyCountByParent = new Map()
133
134 for (const msg of messages) {
135 if (typeof msg !== 'string' || !msg.length) continue
136 const opened = await apds.open(msg)
137 if (opened) {
138 const hash = await apds.hash(msg)
139 if (!hash) continue
140 const ts = parseOpenedTimestamp(opened)
141 const contentHash = opened.substring(13)
142 rowsByHash.set(hash, {
143 hash,
144 ts,
145 opened,
146 author: msg.substring(0, 44),
147 contentHash,
148 })
149 continue
150 }
151 const contentHash = await apds.hash(msg)
152 if (!contentHash) continue
153 try {
154 const yaml = await apds.parseYaml(msg)
155 if (!yaml || typeof yaml !== 'object') continue
156 const replyParent = typeof yaml.replyHash === 'string' && yaml.replyHash.length === 44
157 ? yaml.replyHash
158 : (typeof yaml.reply === 'string' && yaml.reply.length === 44 ? yaml.reply : '')
159 if (replyParent) {
160 replyCountByParent.set(replyParent, (replyCountByParent.get(replyParent) || 0) + 1)
161 }
162 contentByHash.set(contentHash, {
163 name: typeof yaml.name === 'string' ? yaml.name.trim() : '',
164 preview: summarizeText(
165 (typeof yaml.body === 'string' && yaml.body) ||
166 (typeof yaml.bio === 'string' && yaml.bio) ||
167 ''
168 ),
169 replyParent,
170 })
171 } catch {
172 // Ignore invalid YAML content blobs.
173 }
174 }
175
176 const rows = Array.from(rowsByHash.values()).map((row) => {
177 const content = contentByHash.get(row.contentHash) || {}
178 return {
179 hash: row.hash,
180 ts: row.ts,
181 opened: row.opened,
182 author: row.author,
183 contentHash: row.contentHash,
184 name: content.name || '',
185 preview: content.preview || '',
186 replyCount: replyCountByParent.get(row.hash) || 0,
187 }
188 })
189
190 rows.sort((a, b) => b.ts - a.ts)
191 return rows.slice(0, Math.max(1, Math.min(200, limit)))
192}
193
194async function fetchPollRows(upstreamBase, since, limit) {
195 const upstream = new URL('/gossip/poll', upstreamBase)
196 upstream.searchParams.set('since', String(since))
197 const res = await fetch(upstream.toString(), { cache: 'no-store' })
198 if (!res.ok) {
199 throw new Error('upstream poll unavailable')
200 }
201 const data = await res.json()
202 const messages = Array.isArray(data?.messages) ? data.messages : []
203 const rows = await extractFeedRows(messages, limit)
204 const nextSince = Number.isFinite(data?.nextSince) ? data.nextSince : since
205 return { rows, nextSince }
206}
207
208async function toPushPayload(latest, pushIconUrl) {
209 const record = latest && typeof latest === 'object' ? latest : null
210 const hash = record && typeof record.hash === 'string' ? record.hash : ''
211 const explicitUrl = record && typeof record.url === 'string' ? record.url : ''
212 const targetUrl = explicitUrl || (hash ? `https://wiredove.net/#${hash}` : 'https://wiredove.net/')
213 const rawText = record && typeof record.text === 'string' ? record.text : ''
214 const parsed = rawText ? await parsePostText(rawText) : {}
215 const bodyText = parsed.body || ''
216 if (!bodyText.trim()) return null
217 const title = formatPushTitle(parsed.name, record?.author)
218 const body = formatPushBody(bodyText)
219 return JSON.stringify({
220 title,
221 body,
222 url: targetUrl,
223 hash,
224 icon: pushIconUrl,
225 latest,
226 })
227}
228
229export async function createNotificationsService(options = {}) {
230 const settings = {
231 dataDir: DEFAULTS.dataDir,
232 subsFile: DEFAULTS.subsFile,
233 stateFile: DEFAULTS.stateFile,
234 configFile: Deno.env.get('VAPID_CONFIG_PATH') ?? DEFAULTS.configFile,
235 vapidSubject: Deno.env.get('VAPID_SUBJECT') ?? DEFAULTS.vapidSubject,
236 pushIconUrl: Deno.env.get('PUSH_ICON_URL') ?? DEFAULTS.pushIconUrl,
237 feedRowsUpstream: Deno.env.get('FEED_ROWS_UPSTREAM') ?? DEFAULTS.feedRowsUpstream,
238 ...options,
239 }
240
241 await Deno.mkdir(settings.dataDir, { recursive: true })
242
243 const config = await ensureVapidConfig(settings.configFile, settings.vapidSubject)
244 webpush.setVapidDetails(
245 config.vapidSubject,
246 config.vapidPublicKey,
247 config.vapidPrivateKey,
248 )
249
250 async function loadSubscriptions() {
251 return await readJsonFile(settings.subsFile, [])
252 }
253
254 async function saveSubscriptions(subs) {
255 await writeJsonFile(settings.subsFile, subs)
256 }
257
258 async function loadState() {
259 return await readJsonFile(settings.stateFile, {})
260 }
261
262 async function saveState(state) {
263 await writeJsonFile(settings.stateFile, state)
264 }
265
266 async function sendPayloadToSubscriptions(payload) {
267 const subs = await loadSubscriptions()
268 if (subs.length === 0) {
269 return { sent: false, reason: 'no subscriptions' }
270 }
271
272 const now = new Date().toISOString()
273 const nextSubs = []
274
275 for (const sub of subs) {
276 try {
277 await webpush.sendNotification(
278 {
279 endpoint: sub.endpoint,
280 keys: sub.keys,
281 },
282 payload,
283 )
284 nextSubs.push({ ...sub, lastNotifiedAt: now })
285 } catch (err) {
286 const status = err && typeof err === 'object' ? err.statusCode : undefined
287 if (status === 404 || status === 410) {
288 console.warn(`Removing expired subscription: ${sub.id}`)
289 continue
290 }
291 console.error(`Push failed for ${sub.id}`, err)
292 nextSubs.push(sub)
293 }
294 }
295
296 await saveSubscriptions(nextSubs)
297 return { sent: true }
298 }
299
300 async function handleRequest(req) {
301 const url = new URL(req.url)
302
303 if (req.method === 'GET' && url.pathname === '/vapid-public-key') {
304 return Response.json({ key: config.vapidPublicKey })
305 }
306
307 if (req.method === 'POST' && url.pathname === '/subscribe') {
308 const body = await req.json().catch(() => null)
309 if (!body || typeof body !== 'object') {
310 return Response.json({ error: 'invalid subscription' }, { status: 400 })
311 }
312
313 const sub = body
314 if (!sub.endpoint || !sub.keys?.p256dh || !sub.keys?.auth) {
315 return Response.json({ error: 'missing fields' }, { status: 400 })
316 }
317
318 const subs = await loadSubscriptions()
319 const id = subscriptionId(sub.endpoint)
320 const existing = subs.find((item) => item.id === id)
321 if (!existing) {
322 subs.push({
323 id,
324 endpoint: sub.endpoint,
325 keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth },
326 createdAt: new Date().toISOString(),
327 })
328 await saveSubscriptions(subs)
329 }
330
331 return new Response('ok', { status: 200 })
332 }
333
334 if (req.method === 'POST' && url.pathname === '/unsubscribe') {
335 const body = await req.json().catch(() => null)
336 const endpoint = body?.endpoint
337 if (!endpoint) {
338 return Response.json({ error: 'missing endpoint' }, { status: 400 })
339 }
340
341 const subs = await loadSubscriptions()
342 const id = subscriptionId(endpoint)
343 const nextSubs = subs.filter((item) => item.id !== id)
344 if (nextSubs.length !== subs.length) await saveSubscriptions(nextSubs)
345
346 return new Response('ok', { status: 200 })
347 }
348
349 if (req.method === 'POST' && url.pathname === '/push-now') {
350 const body = await req.json().catch(() => null)
351 if (!body || typeof body !== 'object') {
352 return Response.json({ error: 'invalid payload' }, { status: 400 })
353 }
354 const record = {
355 hash: typeof body.hash === 'string' ? body.hash : undefined,
356 author: typeof body.author === 'string' ? body.author : undefined,
357 text: typeof body.text === 'string' ? body.text : undefined,
358 url: typeof body.url === 'string' ? body.url : undefined,
359 }
360
361 const payload = await toPushPayload(record, settings.pushIconUrl)
362 if (!payload) {
363 return Response.json({ sent: false, reason: 'no content' })
364 }
365
366 if (record.hash) {
367 await saveState({ lastSeenId: record.hash })
368 }
369
370 const sendResult = await sendPayloadToSubscriptions(payload)
371 return Response.json({
372 sent: sendResult.sent,
373 reason: sendResult.reason,
374 })
375 }
376
377 if (req.method === 'GET' && url.pathname === '/feed-rows/home') {
378 const sinceRaw = url.searchParams.get('since') || '0'
379 const limitRaw = url.searchParams.get('limit') || '40'
380 const since = Number.parseInt(sinceRaw, 10)
381 const limit = Number.parseInt(limitRaw, 10)
382 const safeSince = Number.isFinite(since) && since > 0 ? since : 0
383 const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 40
384 try {
385 const { rows, nextSince } = await fetchPollRows(settings.feedRowsUpstream, safeSince, safeLimit)
386 return Response.json({ rows, nextSince })
387 } catch (err) {
388 console.error('feed rows fetch failed', err)
389 return Response.json({ error: 'feed-rows-failed' }, { status: 500 })
390 }
391 }
392
393 if (req.method === 'GET' && url.pathname.startsWith('/feed-rows/author/')) {
394 const pubkey = decodeURIComponent(url.pathname.substring('/feed-rows/author/'.length))
395 const sinceRaw = url.searchParams.get('since') || '0'
396 const limitRaw = url.searchParams.get('limit') || '40'
397 const since = Number.parseInt(sinceRaw, 10)
398 const limit = Number.parseInt(limitRaw, 10)
399 const safeSince = Number.isFinite(since) && since > 0 ? since : 0
400 const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 40
401 try {
402 const { rows, nextSince } = await fetchPollRows(settings.feedRowsUpstream, safeSince, safeLimit * 4)
403 const filtered = rows.filter((row) => row.author === pubkey).slice(0, safeLimit)
404 return Response.json({ rows: filtered, nextSince })
405 } catch (err) {
406 console.error('author feed rows fetch failed', err)
407 return Response.json({ error: 'feed-rows-author-failed' }, { status: 500 })
408 }
409 }
410
411 if (req.method === 'GET' && url.pathname.startsWith('/feed-rows/alias/')) {
412 const alias = decodeURIComponent(url.pathname.substring('/feed-rows/alias/'.length))
413 const sinceRaw = url.searchParams.get('since') || '0'
414 const limitRaw = url.searchParams.get('limit') || '40'
415 const since = Number.parseInt(sinceRaw, 10)
416 const limit = Number.parseInt(limitRaw, 10)
417 const safeSince = Number.isFinite(since) && since > 0 ? since : 0
418 const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 40
419 try {
420 const aliasUrl = new URL('/' + alias, settings.feedRowsUpstream)
421 const aliasRes = await fetch(aliasUrl.toString(), { cache: 'no-store' })
422 if (!aliasRes.ok) {
423 return Response.json({ rows: [], nextSince: safeSince })
424 }
425 const aliasData = await aliasRes.json().catch(() => [])
426 const authors = new Set(Array.isArray(aliasData) ? aliasData.filter((item) => typeof item === 'string') : [])
427 if (!authors.size) {
428 return Response.json({ rows: [], nextSince: safeSince })
429 }
430 const { rows, nextSince } = await fetchPollRows(settings.feedRowsUpstream, safeSince, safeLimit * 4)
431 const filtered = rows.filter((row) => authors.has(row.author)).slice(0, safeLimit)
432 return Response.json({ rows: filtered, nextSince })
433 } catch (err) {
434 console.error('alias feed rows fetch failed', err)
435 return Response.json({ error: 'feed-rows-alias-failed' }, { status: 500 })
436 }
437 }
438
439 return null
440 }
441
442 return {
443 config,
444 handleRequest,
445 }
446}