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.
- package/README.md +6 -0
- package/package.json +3 -1
- package/private-messenger/event-store/README.md +110 -0
- package/private-messenger/event-store/helpers/codec.js +136 -0
- package/private-messenger/event-store/index.js +176 -0
- package/private-messenger/file/README.md +27 -2
- package/private-messenger/file/helpers/cache.js +159 -0
- package/private-messenger/file/helpers/catalog.js +23 -1
- package/private-messenger/file/helpers/stream.js +31 -0
- package/private-messenger/file/index.js +42 -18
- package/private-messenger/index.js +27 -10
- package/private-messenger/services/storage-maintenance.js +1 -0
- package/private-messenger/session/README.md +39 -0
- package/private-messenger/session/helpers/publication.js +40 -0
- package/private-messenger/session/helpers/signer.js +13 -0
- package/private-messenger/session/helpers/work-storage.js +32 -0
- package/private-messenger/session/index.js +307 -0
|
@@ -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'
|