libp2r2p 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,307 @@
1
+ import { createEventStoreChunkStorage, createEventStoreMessageStorage } from '../event-store/index.js'
2
+ import { createPrivateFileTransfer } from '../file/index.js'
3
+ import { createPrivateMessenger } from '../index.js'
4
+ import { getEventHash, isValidEvent, isSerializableEvent } from '../../event/index.js'
5
+ import { decodeIrfsChunk, IRFS_CHUNK_BYTES } from '../../irfs/index.js'
6
+ import { decodeFileMetadata } from '../../nip94/index.js'
7
+ import { createChatOutbox } from './helpers/work-storage.js'
8
+ import { messengerSigner } from './helpers/signer.js'
9
+ import { assertMessagePublished } from './helpers/publication.js'
10
+
11
+ export function wireEvent (value, owner) {
12
+ const event = { kind: value.kind, created_at: value.created_at, tags: structuredClone(value.tags), content: value.content, pubkey: value.pubkey || owner }
13
+ if (value.sig) return { ...event, id: value.id, sig: value.sig }
14
+ return event
15
+ }
16
+ // Relay rejection text must not be interpreted as a local signer denial.
17
+ const retryable = error => error?.code === 'MESSAGE_NOT_PUBLISHED' || !/DENIED|PERMISSION|REVOKED|READ_ONLY|INVALID|BLOCKED|EXPIRED|NOT_IN_PERSONA/i.test(`${error?.code || ''} ${error?.message || ''}`)
18
+
19
+ export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], onMedia = () => {}, onOutbox = () => {}, onError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
20
+ const userSigner = messengerSigner(signer)
21
+ messageStorage ||= createEventStoreMessageStorage({ eventStore })
22
+ chunkStorage ||= eventStore ? createEventStoreChunkStorage({ eventStore }) : undefined
23
+ const peers = new Set()
24
+ const channels = new Map()
25
+ const deniedPeers = new Set()
26
+ const entries = new Map()
27
+ const cancelled = new Set()
28
+ let messenger
29
+ let fileTransfers
30
+ let downloads
31
+ const downloadIntents = new Map()
32
+ const downloadEpochs = new Map()
33
+ let downloadWriteTail = Promise.resolve()
34
+ const writeDownloadIntent = operation => {
35
+ const work = downloadWriteTail.catch(() => {}).then(operation)
36
+ downloadWriteTail = work
37
+ return work
38
+ }
39
+ let storage
40
+ let initialized
41
+ let closed = false
42
+ let available = false
43
+ let lifecycle = 0
44
+ let configuring = Promise.resolve()
45
+ let sending
46
+ let draining
47
+ const idleWaiters = new Set()
48
+ const settled = () => { if (!sending && !draining) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
49
+ const emit = () => onOutbox([...entries.values()].filter(entry => !entry.deletion))
50
+ const ready = () => (initialized ??= (async () => {
51
+ storage = await openOutbox({ owner, signer })
52
+ for (const entry of await storage.list()) entries.set(entry.id, entry)
53
+ emit()
54
+ })().catch(async error => { await storage?.close(); storage = null; initialized = null; throw error }))
55
+ async function configure () {
56
+ if (closed || !available) return
57
+ await ready()
58
+ const version = lifecycle
59
+ const values = []
60
+ for (const peer of peers) {
61
+ if (deniedPeers.has(peer)) continue
62
+ let channel = channels.get(peer)
63
+ if (!channel) {
64
+ try {
65
+ const scoped = messengerSigner(signer.withSharedKey(peer, 'dm'))
66
+ channel = { signer: scoped, pubkey: await scoped.getPublicKey(), mode, seeders: seedersForPeer(peer) }
67
+ channels.set(peer, channel)
68
+ } catch (error) { if (!retryable(error)) deniedPeers.add(peer); onError(error); continue }
69
+ }
70
+ if (closed || !available || version !== lifecycle) return
71
+ values.push(channel)
72
+ }
73
+ if (!messenger) messenger = await Messenger({ seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys: false, onMessageQueued: () => drain(), onError })
74
+ if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
75
+ await messenger.update({ channels: values })
76
+ await messenger.resume('signer')
77
+ if (!fileTransfers) {
78
+ fileTransfers = FileTransfer({
79
+ messenger, onError,
80
+ resolveChannel: ({ peerPubkey, info }) => messengerSigner(signer.withSharedKey(peerPubkey, info)),
81
+ storage: chunkStorage,
82
+ seedStorage: recoveryStorage?.seeds,
83
+ authorizationStorage: recoveryStorage?.authorizations
84
+ })
85
+ fileTransfers.observe(state => {
86
+ const peer = [...channels].find(([, value]) => value.pubkey === state.controlChannelPubkey)?.[0]
87
+ if (peer) {
88
+ onMedia({ ...state, peer })
89
+ if (state.status === 'complete') {
90
+ const id = intentId({ peer, root: state.root })
91
+ if (downloadIntents.delete(id)) writeDownloadIntent(() => downloads?.remove(id)).catch(onError)
92
+ }
93
+ }
94
+ })
95
+ downloads = await openDownloads({ owner, signer })
96
+ for (const entry of await downloads.list()) downloadIntents.set(entry.id, entry)
97
+ }
98
+ for (const entry of downloadIntents.values()) if (peers.has(entry.file.peer)) download(entry.file, { manual: true }).catch(onError)
99
+ drain(); pump()
100
+ }
101
+ function schedule () {
102
+ const work = configuring.catch(() => {}).then(configure)
103
+ configuring = work
104
+ work.catch(onError)
105
+ return work
106
+ }
107
+ async function drain () {
108
+ if (draining || closed || !available || !messenger) return
109
+ draining = true
110
+ const deferred = []
111
+ try {
112
+ while (true) {
113
+ if (closed || !available) break
114
+ const delivery = await messenger.nextMessage()
115
+ if (!delivery) break
116
+ const { message, ack, nack } = delivery
117
+ try {
118
+ const peer = [...channels].find(([, value]) => value.pubkey === message.channelPubkey)?.[0]
119
+ if (!peer) { deferred.push(nack); continue }
120
+ if (message.senderPubkey !== peer) { await ack(); continue }
121
+ if (!peers.has(peer)) { deferred.push(nack); continue }
122
+ const event = wireEvent(message.event, peer)
123
+ const valid = event.pubkey === peer || message.provenance === 'hearsay' || message.provenance === 'signed'
124
+ if (!valid || !isSerializableEvent(event) || !allowedKinds.includes(event.kind) || (event.sig && !isValidEvent(event)) || getEventHash(event) !== message.event.id) { await ack(); continue }
125
+ if (event.kind === 34601) {
126
+ try { decodeIrfsChunk(event) } catch (error) { onError(error); await ack(); continue }
127
+ }
128
+ if (event.kind === 5 && (message.provenance === 'hearsay' || event.pubkey !== peer)) { await ack(); continue }
129
+ if (!available || closed) { await nack(); break }
130
+ const saved = await messageStorage.save(event, { peerPubkey: peer, hearsay: message.provenance === 'hearsay' })
131
+ if (!saved?.result?.ok && !['blocked', 'expired'].includes(saved?.result?.code)) throw Object.assign(new Error('INBOX_STORAGE_FAILED'), { code: saved?.result?.code?.toUpperCase() })
132
+ await ack()
133
+ } catch (error) {
134
+ await nack()
135
+ onError(error)
136
+ // Retain the failed record and suspend network ingestion until recovery.
137
+ await messenger.pause('storage')
138
+ break
139
+ }
140
+ }
141
+ } finally { await Promise.all(deferred.map(nack => nack())); draining = false; settled() }
142
+ }
143
+ async function publish (peer, event) {
144
+ if (deniedPeers.has(peer)) throw new Error('PERMISSION_DENIED')
145
+ if (!available || !peers.has(peer) || !channels.has(peer)) throw new Error('CHAT_UNAVAILABLE')
146
+ const options = { channelPubkey: channels.get(peer)?.pubkey, receiverPubkeys: [peer] }
147
+ const report = event.sig ? await messenger.broadcastEvent({ ...options, event }) : await messenger.broadcastRumor({ ...options, rumor: event })
148
+ // Published is relay acceptance, never a peer receipt.
149
+ await assertMessagePublished(report, event)
150
+ }
151
+ async function sendEntry (entry) {
152
+ entry.status = 'pending'; emit()
153
+ try {
154
+ const active = async () => {
155
+ if (storage.has && !await storage.has(entry.id)) cancelled.add(entry.id)
156
+ if (closed || !available || cancelled.has(entry.id) || (entry.peer !== owner && !peers.has(entry.peer))) throw new Error('CHAT_UNAVAILABLE')
157
+ }
158
+ // Personal copies must commit even if publishing attachment bytes
159
+ // fails offline. Remote progress remains independently retryable.
160
+ for (let index = 0; index < entry.events.length; index++) {
161
+ await active()
162
+ const event = entry.events[index]
163
+ if (!entry.localSaved[index]) {
164
+ const hearsay = !event.sig && event.pubkey !== owner
165
+ const result = await messageStorage.save(event, { peerPubkey: entry.peer, hearsay })
166
+ if (result?.result?.code === 'blocked') { cancelled.add(entry.id); await active() }
167
+ if (!result?.result?.ok) throw Object.assign(new Error('MESSAGE_STORAGE_FAILED'), { code: result?.result?.code?.toUpperCase() })
168
+ entry.localSaved[index] = true
169
+ await storage.put(entry, { existing: true })
170
+ }
171
+ }
172
+ // Read/publish one chunk at a time. Progress is durable after each
173
+ // accepted chunk; replay after interruption keeps its original identity.
174
+ for (; entry.peer !== owner && entry.fileIndex < entry.files.length; entry.fileIndex++, entry.chunkIndex = 0) {
175
+ const file = entry.files[entry.fileIndex]
176
+ await active()
177
+ await fileTransfers.authorizeSeeding({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, { receiverPubkeys: [entry.peer], sharedAt: entry.event.created_at })
178
+ for (; entry.chunkIndex < Math.ceil(file.size / IRFS_CHUNK_BYTES); entry.chunkIndex++) {
179
+ await active()
180
+ const chunk = chunkStorage ? await chunkStorage.read(file.root, entry.chunkIndex, file) : await fileTransfers.readChunk(file.root, entry.chunkIndex)
181
+ if (!chunk || decodeIrfsChunk(chunk).root !== file.root) { if (file.optional) break; throw new Error('FILE_UNAVAILABLE') }
182
+ if (!fileTransfers) throw new Error('FILE_TRANSFER_UNAVAILABLE')
183
+ await fileTransfers.publishChunk({ controlChannelPubkey: channels.get(entry.peer).pubkey, peerPubkey: entry.peer, root: file.root, size: file.size }, wireEvent(chunk, owner))
184
+ const completed = entry.files.slice(0, entry.fileIndex).reduce((sum, value) => sum + value.size, 0) + Math.min(file.size, (entry.chunkIndex + 1) * IRFS_CHUNK_BYTES)
185
+ entry.uploadProgress = { completed, total: entry.files.reduce((sum, value) => sum + value.size, 0) }
186
+ emit()
187
+ await active()
188
+ await storage.put({ ...entry, chunkIndex: entry.chunkIndex + 1 }, { existing: true })
189
+ // Newly queued text and deletion controls can pass an in-flight file.
190
+ let admitted = 0
191
+ for (const pending of entries.values()) {
192
+ if (closed || !available || admitted >= 8) break
193
+ if (pending.files.length || pending.failed || cancelled.has(pending.id) || (pending.peer !== owner && !peers.has(pending.peer))) continue
194
+ admitted++
195
+ await sendEntry(pending)
196
+ }
197
+ }
198
+ }
199
+ for (; entry.index < entry.events.length; entry.index++) {
200
+ await active()
201
+ const event = entry.events[entry.index]
202
+ if (entry.peer !== owner) await publish(entry.peer, event)
203
+ await active()
204
+ await storage.put({ ...entry, index: entry.index + 1 }, { existing: true })
205
+ }
206
+ await storage.remove(entry.id)
207
+ entries.delete(entry.id)
208
+ emit()
209
+ } catch (error) {
210
+ if (cancelled.has(entry.id)) { await storage.remove(entry.id); entries.delete(entry.id) } else { entry.status = 'error'; entry.failed = true; entry.retryable = retryable(error); await storage.put(entry, { existing: true }).catch(onError); onError(error) }
211
+ emit()
212
+ }
213
+ }
214
+ async function pump () {
215
+ if (sending || !available || closed || !messenger || !storage) return
216
+ sending = true
217
+ try {
218
+ for (const entry of entries.values()) {
219
+ if (closed || !available) break
220
+ if ((entry.peer !== owner && !peers.has(entry.peer)) || entry.failed || cancelled.has(entry.id)) continue
221
+ await sendEntry(entry)
222
+ }
223
+ } finally { sending = false; settled() }
224
+ }
225
+ const intentId = file => getEventHash({ kind: 0, pubkey: owner, created_at: 0, tags: [], content: `${file.peer}:${file.root}` })
226
+ async function download (file, options = {}) {
227
+ if (file.peer === owner) return file
228
+ if (!available || closed || !peers.has(file.peer) || !fileTransfers) throw new Error('CHAT_UNAVAILABLE')
229
+ const id = intentId(file)
230
+ if (options.manual) {
231
+ const epoch = downloadEpochs.get(id) || 0
232
+ const cancelled = () => { if ((downloadEpochs.get(id) || 0) !== epoch) throw new DOMException('Download cancelled', 'AbortError') }
233
+ await writeDownloadIntent(async () => {
234
+ cancelled()
235
+ await downloads.put({ id, file })
236
+ cancelled()
237
+ downloadIntents.set(id, { id, file })
238
+ })
239
+ }
240
+ if (!available || closed || !peers.has(file.peer)) throw new Error('CHAT_UNAVAILABLE')
241
+ const result = await fileTransfers.download({ controlChannelPubkey: channels.get(file.peer).pubkey, peerPubkey: file.peer, root: file.root, size: file.size, sharedAt: file.sharedAt }, options)
242
+ if (downloadIntents.delete(id)) await writeDownloadIntent(() => downloads.remove(id))
243
+ return result
244
+ }
245
+ async function cancelDownload (file) {
246
+ fileTransfers?.cancel(channels.get(file.peer)?.pubkey, file.root)
247
+ const id = intentId(file)
248
+ downloadEpochs.set(id, (downloadEpochs.get(id) || 0) + 1)
249
+ downloadIntents.delete(id)
250
+ await writeDownloadIntent(() => downloads?.remove(id))
251
+ }
252
+ return {
253
+ download, cancelDownload,
254
+ async setPeers (values) {
255
+ peers.clear(); for (const peer of values) if (peer !== owner) peers.add(peer)
256
+ for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
257
+ lifecycle++
258
+ return schedule()
259
+ },
260
+ async setAvailable (value) {
261
+ available = value === true
262
+ lifecycle++
263
+ if (!available) { await messenger?.pause('signer'); return }
264
+ await ready()
265
+ for (const entry of entries.values()) if (entry.retryable) entry.failed = false
266
+ await messenger?.resume('storage')
267
+ return schedule()
268
+ },
269
+ async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false }) {
270
+ if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
271
+ await ready()
272
+ const main = wireEvent(event, owner)
273
+ const id = getEventHash(main)
274
+ const events = [...context.map(value => wireEvent(value, owner)), main]
275
+ const files = events.filter(value => value.kind === 1063).map(value => ({ ...decodeFileMetadata(value), optional: !requiredFiles.includes(getEventHash(value)) })).filter(file => file.service === 'irfs').flatMap(file => [file, ...(file.thumbnail?.root ? [{ ...file.thumbnail, optional: file.optional }] : [])])
276
+ const entry = { id, peer, event: { ...main, id }, events, files, index: 0, fileIndex: 0, chunkIndex: 0, localSaved: context.map(() => true).concat(false), status: 'pending', deletion }
277
+ await storage.put(entry)
278
+ entries.set(id, entry); emit(); pump()
279
+ return id
280
+ },
281
+ async retry (id) {
282
+ await ready()
283
+ const entry = entries.get(id)
284
+ if (entry) { entry.failed = false; if (deniedPeers.delete(entry.peer)) await schedule(); return pump() }
285
+ },
286
+ async cancel (id) {
287
+ cancelled.add(id)
288
+ await ready()
289
+ entries.delete(id)
290
+ await storage.remove(id)
291
+ emit()
292
+ },
293
+ async close () {
294
+ closed = true; available = false; lifecycle++
295
+ await Promise.allSettled([configuring, initialized])
296
+ await messenger?.pause('closed')
297
+ if (sending || draining) await new Promise(resolve => idleWaiters.add(resolve))
298
+ await messenger?.close()
299
+ await storage?.close()
300
+ await downloadWriteTail.catch(onError)
301
+ await downloads?.close()
302
+ }
303
+ }
304
+ }
305
+
306
+ export { createChatOutbox as createPrivateSessionStorage } from './helpers/work-storage.js'
307
+ export { messengerSigner as createMessengerSigner } from './helpers/signer.js'