1 // smesh2 service worker — thin orchestrator
2 // delegates to crypto.js, db.js, pool.js, dm.js
3
4 import { signEvent, schnorr, bytesToHex } from './crypto.js'
5 import { saveEvent, queryEvents, getConversationList, queryDMs } from './db.js'
6 import {
7 setPoolState, setCallbacks, pool, subs,
8 sendToRelay, handleProxy, handleEvent, handleRelayInfo,
9 } from './pool.js'
10 import {
11 setDMState, processIncomingDM, sendDM, handleDMSub,
12 handleExtDMResult, handleCryptoResult,
13 } from './dm.js'
14
15 const CACHE_NAME = 'smesh2-v52'
16 const CACHE_URLS = ['./', './favicon.ico', './favicon.png', './favicon-96x96.png', './apple-touch-icon.png']
17
18 // ─── module state ───────────────────────────────────────────────────
19
20 let secretKey = null
21 let secretKeyHex = null
22 let myPubkey = null
23 let writeRelays = []
24
25 function syncState() {
26 setPoolState({ writeRelays, secretKey, secretKeyHex, myPubkey })
27 setDMState({ secretKey, secretKeyHex, myPubkey })
28 }
29
30 // ─── lifecycle ──────────────────────────────────────────────────────
31
32 self.addEventListener('install', (e) => {
33 e.waitUntil(
34 caches.open(CACHE_NAME)
35 .then((cache) => cache.addAll(CACHE_URLS))
36 .catch((err) => console.warn('cache addAll failed, continuing:', err))
37 )
38 self.skipWaiting()
39 })
40
41 self.addEventListener('activate', (e) => {
42 e.waitUntil(
43 caches.keys().then((names) =>
44 Promise.all(
45 names
46 .filter((n) => n !== CACHE_NAME)
47 .map((n) => caches.delete(n))
48 )
49 )
50 )
51 self.clients.claim()
52 })
53
54 self.addEventListener('fetch', (e) => {
55 const url = new URL(e.request.url)
56 if (url.origin !== self.location.origin) return
57 if (e.request.mode === 'navigate') {
58 e.respondWith(
59 fetch(e.request).catch(() => caches.match(e.request))
60 )
61 return
62 }
63 e.respondWith(
64 caches.match(e.request).then((cached) => cached || fetch(e.request))
65 )
66 })
67
68 // ─── subscriptions ──────────────────────────────────────────────────
69
70 async function handleReq(clientId, subId, filter) {
71 subs.set(subId, { filter, clientId })
72 const events = await queryEvents(filter)
73 const client = await self.clients.get(clientId)
74 if (!client) return
75 for (const ev of events) client.postMessage(['EVENT', subId, ev])
76 client.postMessage(['EOSE', subId])
77 }
78
79 function handleClose(subId) {
80 subs.delete(subId)
81 }
82
83 async function pushToMatchingSubs(event) {
84 for (const [subId, { filter, clientId }] of subs) {
85 if (matchesFilter(event, filter)) {
86 const client = await self.clients.get(clientId)
87 if (client) client.postMessage(['EVENT', subId, event])
88 }
89 }
90 }
91
92 function matchesFilter(ev, f) {
93 if (f.ids && !f.ids.includes(ev.id)) return false
94 if (f.authors && !f.authors.includes(ev.pubkey)) return false
95 if (f.kinds && !f.kinds.includes(ev.kind)) return false
96 if (f.since && ev.created_at < f.since) return false
97 if (f.until && ev.created_at > f.until) return false
98 for (const [k, v] of Object.entries(f)) {
99 if (k.startsWith('#') && k.length === 2) {
100 const tagName = k[1]
101 if (!ev.tags?.some((t) => t[0] === tagName && v.includes(t[1]))) return false
102 }
103 }
104 return true
105 }
106
107 // ─── identity broadcast ─────────────────────────────────────────────
108
109 async function broadcastIdentity(clientId, pubkey, relayUrls) {
110 const events = await queryEvents({ authors: [pubkey], kinds: [0, 3, 10002, 10050, 10051] })
111 const byKind = new Map()
112 for (const ev of events) {
113 const prev = byKind.get(ev.kind)
114 if (!prev || ev.created_at > prev.created_at) byKind.set(ev.kind, ev)
115 }
116
117 const relayEvent = byKind.get(10002)
118 const userRelays = relayEvent
119 ? relayEvent.tags.filter((t) => t[0] === 'r').map((t) => t[1])
120 : writeRelays.length ? writeRelays : []
121
122 if (!byKind.has(10050) && secretKey && userRelays.length) {
123 const ev = signEvent({
124 pubkey,
125 created_at: Math.floor(Date.now() / 1000),
126 kind: 10050,
127 tags: userRelays.map((r) => ['relay', r]),
128 content: ''
129 }, secretKey)
130 await saveEvent(ev)
131 byKind.set(10050, ev)
132 }
133
134 if (!byKind.has(10051) && secretKey && userRelays.length) {
135 const ev = signEvent({
136 pubkey,
137 created_at: Math.floor(Date.now() / 1000),
138 kind: 10051,
139 tags: userRelays.map((r) => ['relay', r]),
140 content: ''
141 }, secretKey)
142 await saveEvent(ev)
143 byKind.set(10051, ev)
144 }
145
146 const toSend = [...byKind.values()]
147 if (!toSend.length) {
148 const client = await self.clients.get(clientId)
149 if (client) client.postMessage(['BROADCAST_DONE', 0, 0])
150 return
151 }
152
153 for (const ev of toSend) {
154 for (const url of relayUrls) sendToRelay(url, ['EVENT', ev])
155 }
156 const client = await self.clients.get(clientId)
157 if (client) client.postMessage(['BROADCAST_DONE', toSend.length, relayUrls.length])
158 }
159
160 // ─── wire up pool callbacks ─────────────────────────────────────────
161
162 async function broadcastToClients(msg) {
163 const clients = await self.clients.matchAll()
164 for (const client of clients) client.postMessage(msg)
165 }
166
167 setCallbacks({
168 onEvent: pushToMatchingSubs,
169 onDMEvent: processIncomingDM,
170 broadcastToClients,
171 })
172
173 // ─── message dispatch ───────────────────────────────────────────────
174
175 self.addEventListener('message', (e) => {
176 const [type, ...args] = e.data
177 const clientId = e.source?.id
178
179 switch (type) {
180 case 'REQ': handleReq(clientId, args[0], args[1]); break
181 case 'CLOSE': handleClose(args[0]); break
182 case 'EVENT': handleEvent(clientId, args[0]); break
183 case 'PROXY': handleProxy(clientId, args[0], args[1], ...args.slice(2)); break
184 case 'RELAY_INFO': handleRelayInfo(clientId, args[0]); break
185 case 'SKIP_WAITING': self.skipWaiting(); break
186
187 case 'SET_KEY': {
188 secretKey = new Uint8Array(args[0])
189 secretKeyHex = Array.from(secretKey, (b) => b.toString(16).padStart(2, '0')).join('')
190 myPubkey = bytesToHex(schnorr.getPublicKey(secretKey))
191 syncState()
192 self.clients.get(clientId).then((c) => c?.postMessage(['KEY_SET']))
193 break
194 }
195 case 'SET_PUBKEY': {
196 myPubkey = args[0]
197 syncState()
198 break
199 }
200 case 'SIGN': {
201 const [requestId, unsignedEvent] = args
202 if (!secretKey) break
203 try {
204 const signed = signEvent(unsignedEvent, secretKey)
205 self.clients.get(clientId).then((c) => {
206 if (c) c.postMessage(['SIGNED', requestId, signed])
207 })
208 } catch (err) {
209 self.clients.get(clientId).then((c) => {
210 if (c) c.postMessage(['SIGN_ERROR', requestId, err.message])
211 })
212 }
213 break
214 }
215 case 'CLEAR_KEY': {
216 secretKey = null
217 secretKeyHex = null
218 myPubkey = null
219 writeRelays = []
220 syncState()
221 break
222 }
223 case 'SET_WRITE_RELAYS': {
224 writeRelays = args[0] || []
225 syncState()
226 break
227 }
228 case 'BROADCAST': {
229 broadcastIdentity(clientId, args[0], args[1])
230 break
231 }
232 case 'SEND_DM': {
233 sendDM(clientId, args[0], args[1], args[2])
234 break
235 }
236 case 'DM_EXT_RESULT': {
237 const [peer, content, success, errMsg] = args
238 handleExtDMResult(clientId, peer, content, success, errMsg)
239 break
240 }
241 case 'DM_SUB': {
242 handleDMSub(clientId, args[0])
243 break
244 }
245 case 'DM_LIST': {
246 getConversationList().then((list) => {
247 self.clients.get(clientId).then((c) => {
248 if (c) c.postMessage(['DM_LIST', list])
249 })
250 })
251 break
252 }
253 case 'DM_HISTORY': {
254 const [peer, limit, until] = args
255 queryDMs(peer, limit || 50, until).then((messages) => {
256 self.clients.get(clientId).then((c) => {
257 if (c) c.postMessage(['DM_HISTORY', peer, messages])
258 })
259 })
260 break
261 }
262 case 'CRYPTO_RESULT': {
263 const [requestId, result, error] = args
264 handleCryptoResult(requestId, result, error)
265 break
266 }
267 }
268 })
269