libp2r2p 0.6.0 → 0.7.0
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 +50 -13
- package/idb-queue/index.js +37 -11
- package/package.json +1 -1
- package/private-channel/index.js +35 -18
- package/private-channel/services/received-chunks.js +71 -30
- package/private-message/index.js +58 -13
- package/private-messenger/index.js +357 -70
- package/private-messenger/recovery/index.js +3 -1
- package/private-messenger/services/channel-state.js +33 -4
- package/private-messenger/services/storage-maintenance.js +324 -0
package/README.md
CHANGED
|
@@ -20,10 +20,12 @@ import { createPrivateMessenger } from 'libp2r2p/private-messenger'
|
|
|
20
20
|
const messenger = await createPrivateMessenger({
|
|
21
21
|
userSigner,
|
|
22
22
|
contentKeySigner,
|
|
23
|
+
offlineRecoverySeconds: 7 * 24 * 60 * 60,
|
|
23
24
|
channels: [{
|
|
24
25
|
signer: privateChannelSigner,
|
|
25
26
|
relays: ['wss://relay.example'],
|
|
26
|
-
mode: 'leecher'
|
|
27
|
+
mode: 'leecher',
|
|
28
|
+
offlineRecoverySeconds: 30 * 24 * 60 * 60
|
|
27
29
|
}],
|
|
28
30
|
onError: err => console.warn('private messenger failed', err)
|
|
29
31
|
})
|
|
@@ -126,45 +128,80 @@ one `['k', '3560']` tag, explicit matching `e` targets, and no `a` tags. A
|
|
|
126
128
|
regular NIP-09 kind `5` request signed by the outer event's private-channel key
|
|
127
129
|
must not delete a kind `3560` event, whether or not that event has an `s` tag.
|
|
128
130
|
|
|
129
|
-
###
|
|
131
|
+
### Storage Maintenance
|
|
130
132
|
|
|
131
133
|
While an outgoing private message is being assembled, the messenger keeps
|
|
132
134
|
encrypted envelope rows and router chunks in `sessionStorage`. They are
|
|
133
135
|
removed when the send finishes, but an interrupted browser operation can leave
|
|
134
136
|
them behind until cleanup runs.
|
|
135
137
|
|
|
136
|
-
`PrivateMessenger.init()`
|
|
137
|
-
`PrivateMessenger.
|
|
138
|
-
|
|
138
|
+
`PrivateMessenger.init()` awaits storage maintenance automatically. Call
|
|
139
|
+
`PrivateMessenger.maintainStorage()` during app startup when messenger
|
|
140
|
+
initialization may be delayed, such as while an account is locked:
|
|
139
141
|
|
|
140
142
|
```js
|
|
141
143
|
import { PrivateMessenger } from 'libp2r2p/private-messenger'
|
|
142
144
|
|
|
143
|
-
PrivateMessenger.
|
|
145
|
+
PrivateMessenger.maintainStorage().catch(console.warn)
|
|
144
146
|
```
|
|
145
147
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
Maintenance removes interrupted-send staging, expired receive chunks, and
|
|
149
|
+
storage belonging to inactive principal identities. It also
|
|
150
|
+
resumes any interrupted database-set deletion. An application does not need to
|
|
151
|
+
know database names or enumerate IndexedDB. Pass `temporaryStorageArea` only
|
|
152
|
+
when the messenger was configured to use a Storage area other than the default
|
|
153
|
+
`sessionStorage`.
|
|
154
|
+
|
|
155
|
+
Each principal signer owns internal message, recovery-seed, and channel-state
|
|
156
|
+
databases. The messenger updates their activity lease while it is open and
|
|
157
|
+
closes all handles in `await messenger.close()`. The complete set is removed
|
|
158
|
+
after 60 days without use, including messages that were never consumed.
|
|
159
|
+
Maintenance runs on every initialization and every six hours while a messenger
|
|
160
|
+
is active; failed deletions remain journaled and retry automatically.
|
|
161
|
+
|
|
162
|
+
Channel recovery state inside an otherwise active identity retains its separate
|
|
163
|
+
45-day stale-channel cleanup policy. Offline recovery defaults to seven days.
|
|
164
|
+
Set `offlineRecoverySeconds` on an individual channel to override the
|
|
165
|
+
messenger default for its recovery seeds, offline ranges, new outer-event
|
|
166
|
+
expiration, and new incomplete receive groups. Updating a channel applies a
|
|
167
|
+
shorter window immediately to its stored seeds and ranges; increasing it does
|
|
168
|
+
not recreate data already removed.
|
|
169
|
+
|
|
170
|
+
Set a channel's `offlineRecoverySeconds` to `0` to disable durable recovery for
|
|
171
|
+
that channel. The messenger then stores no recovery seeds, tracks no offline
|
|
172
|
+
ranges, contacts no seeders, publishes no seeder presence, and uses no recovery
|
|
173
|
+
mirror relays. Live delivery remains usable: new outer events retain the
|
|
174
|
+
private-channel two-day technical expiration and incomplete receive groups use
|
|
175
|
+
the one-hour technical TTL. Existing signed events and receive groups retain
|
|
176
|
+
the deadlines chosen when they were created.
|
|
150
177
|
|
|
151
178
|
Recovery metadata is separate from that temporary send staging. Per-channel
|
|
152
179
|
`lastSeenAt`, offline ranges, and related state are stored in IndexedDB. Raw
|
|
153
|
-
incomplete receive chunks are also stored in IndexedDB
|
|
154
|
-
|
|
180
|
+
incomplete receive chunks are also stored in IndexedDB and share a 16 MiB
|
|
181
|
+
logical budget. Direct private-channel calls give each new group a one-hour
|
|
182
|
+
TTL; PrivateMessenger groups use the effective recovery window of their
|
|
183
|
+
channel, or one hour when durable recovery is disabled. The TTL is persisted
|
|
184
|
+
per group, so another caller opening the shared database or a later channel
|
|
185
|
+
configuration update cannot change it. Capacity
|
|
186
|
+
eviction removes whole
|
|
155
187
|
least-recently-used message groups so a partial group is never mistaken for a
|
|
156
188
|
complete one. `receivedChunkTtlMs`, `receivedChunkMaxBytes`, and
|
|
157
189
|
`receivedChunkIndexedDB` may be supplied to the private-channel APIs when an
|
|
158
190
|
embedding environment needs different limits or an injected IDB factory.
|
|
159
191
|
Legacy Web Storage recovery records are neither read nor migrated.
|
|
160
192
|
|
|
193
|
+
The recovery-seed queue has a shared 64 MiB logical budget by default and uses
|
|
194
|
+
FIFO eviction. A channel recovery duration is therefore a maximum retention
|
|
195
|
+
window, not a guarantee that every seed remains available until its deadline.
|
|
196
|
+
|
|
161
197
|
Signers are expected to expose the Nostr-style methods used by the messenger,
|
|
162
198
|
including `getPublicKey()`, `signEvent(event)`, and the NIP-44 v3 methods
|
|
163
199
|
needed by private channels. For double-DH content-key use, pass a
|
|
164
200
|
`contentKeySigner` or a signer implementation that handles content keys
|
|
165
201
|
internally.
|
|
166
202
|
|
|
167
|
-
Messages are stored in a bounded, durable IndexedDB queue until consumed
|
|
203
|
+
Messages are stored in a bounded, durable IndexedDB queue until consumed or
|
|
204
|
+
until the principal identity has been inactive for 60 days:
|
|
168
205
|
|
|
169
206
|
```js
|
|
170
207
|
async function handleMessages () {
|
package/idb-queue/index.js
CHANGED
|
@@ -274,6 +274,10 @@ export async function createQueue ({
|
|
|
274
274
|
const waiters = new Set()
|
|
275
275
|
let sessionMaxBytes = configuredMaxBytes
|
|
276
276
|
let revision = 0
|
|
277
|
+
let closed = false
|
|
278
|
+
let activeTransactions = 0
|
|
279
|
+
let closePromise = null
|
|
280
|
+
const closeWaiters = new Set()
|
|
277
281
|
|
|
278
282
|
function hasByteLimit () {
|
|
279
283
|
return Number.isFinite(sessionMaxBytes)
|
|
@@ -317,17 +321,27 @@ export async function createQueue ({
|
|
|
317
321
|
}
|
|
318
322
|
|
|
319
323
|
async function transaction (mode, work) {
|
|
320
|
-
|
|
321
|
-
|
|
324
|
+
if (closed) throw new Error('QUEUE_CLOSED')
|
|
325
|
+
activeTransactions++
|
|
322
326
|
try {
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
327
|
+
const tx = db.transaction([ITEMS_STORE, STATE_STORE], mode)
|
|
328
|
+
const done = transactionDone(tx)
|
|
329
|
+
try {
|
|
330
|
+
// Keep work limited to IndexedDB requests so the transaction stays active.
|
|
331
|
+
const value = await work(tx)
|
|
332
|
+
await done
|
|
333
|
+
return value
|
|
334
|
+
} catch (err) {
|
|
335
|
+
try { tx.abort() } catch {}
|
|
336
|
+
try { await done } catch {}
|
|
337
|
+
throw err
|
|
338
|
+
}
|
|
339
|
+
} finally {
|
|
340
|
+
activeTransactions--
|
|
341
|
+
if (!activeTransactions) {
|
|
342
|
+
for (const resolve of closeWaiters) resolve()
|
|
343
|
+
closeWaiters.clear()
|
|
344
|
+
}
|
|
331
345
|
}
|
|
332
346
|
}
|
|
333
347
|
|
|
@@ -771,6 +785,17 @@ export async function createQueue ({
|
|
|
771
785
|
for (const record of (await snapshotStoredItems()).reverse()) yield record.item
|
|
772
786
|
}
|
|
773
787
|
|
|
788
|
+
function close () {
|
|
789
|
+
if (closePromise) return closePromise
|
|
790
|
+
closed = true
|
|
791
|
+
wake()
|
|
792
|
+
closePromise = (activeTransactions
|
|
793
|
+
? new Promise(resolve => closeWaiters.add(resolve))
|
|
794
|
+
: Promise.resolve())
|
|
795
|
+
.then(() => { db.close() })
|
|
796
|
+
return closePromise
|
|
797
|
+
}
|
|
798
|
+
|
|
774
799
|
async function * storedItemsBy (indexName, query, { direction = 'next' } = {}) {
|
|
775
800
|
direction = validDirection(direction)
|
|
776
801
|
const records = await snapshot(async tx => {
|
|
@@ -811,6 +836,7 @@ export async function createQueue ({
|
|
|
811
836
|
getBy,
|
|
812
837
|
someBy,
|
|
813
838
|
removeBy,
|
|
814
|
-
storedItemsBy
|
|
839
|
+
storedItemsBy,
|
|
840
|
+
close
|
|
815
841
|
}
|
|
816
842
|
}
|
package/package.json
CHANGED
package/private-channel/index.js
CHANGED
|
@@ -566,8 +566,8 @@ async function decryptRouter ({ content, channelPubkey, channelSigner, channelRe
|
|
|
566
566
|
|
|
567
567
|
function readValueFromMap (map, key) {
|
|
568
568
|
if (!map || !key) return null
|
|
569
|
-
if (map instanceof Map) return map.get(key)
|
|
570
|
-
return map[key]
|
|
569
|
+
if (map instanceof Map) return map.get(key) ?? null
|
|
570
|
+
return map[key] ?? null
|
|
571
571
|
}
|
|
572
572
|
|
|
573
573
|
function privateChannelPubkeyList ({ privateChannelPubkey, privateChannelPubkeys }) {
|
|
@@ -669,6 +669,7 @@ function createProcessor ({
|
|
|
669
669
|
onContentKeyUsage,
|
|
670
670
|
onError,
|
|
671
671
|
receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
|
|
672
|
+
receivedChunkTtlMsByPubkey,
|
|
672
673
|
receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES,
|
|
673
674
|
receivedChunkIndexedDB = globalThis.indexedDB,
|
|
674
675
|
ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS,
|
|
@@ -683,8 +684,11 @@ function createProcessor ({
|
|
|
683
684
|
ttlMs: ignoredGroupTtlMs,
|
|
684
685
|
maxEntries: ignoredGroupMaxEntries
|
|
685
686
|
})
|
|
687
|
+
const storeReady = receivedChunks.ready()
|
|
688
|
+
storeReady.catch(() => {})
|
|
686
689
|
|
|
687
|
-
|
|
690
|
+
async function processOuterEvent (outer) {
|
|
691
|
+
await storeReady
|
|
688
692
|
let groupKey = ''
|
|
689
693
|
try {
|
|
690
694
|
const channelPubkey = outer.pubkey || await privateChannelSigner?.getPublicKey?.()
|
|
@@ -692,6 +696,7 @@ function createProcessor ({
|
|
|
692
696
|
const channelReaderSigner = readSignerFromMap(privateChannelReaderSignersByPubkey, channelPubkey) || privateChannelReaderSigner || channelSigner
|
|
693
697
|
const channelReaderPubkey = readValueFromMap(privateChannelReaderPubkeysByPubkey, channelPubkey) || privateChannelReaderPubkey || channelPubkey
|
|
694
698
|
const channelMode = readValueFromMap(modeByPubkey, channelPubkey) || mode
|
|
699
|
+
const channelReceivedChunkTtlMs = readValueFromMap(receivedChunkTtlMsByPubkey, channelPubkey) ?? receivedChunkTtlMs
|
|
695
700
|
if (!channelPubkey) throw new Error('PRIVATE_CHANNEL_PUBKEY_REQUIRED')
|
|
696
701
|
|
|
697
702
|
const decrypted = await decryptRouter({
|
|
@@ -712,7 +717,8 @@ function createProcessor ({
|
|
|
712
717
|
routerPubkey: nymCarrierGroupId(carrier),
|
|
713
718
|
index,
|
|
714
719
|
total,
|
|
715
|
-
contentBytes: encoder.encode(JSON.stringify(carrier))
|
|
720
|
+
contentBytes: encoder.encode(JSON.stringify(carrier)),
|
|
721
|
+
ttlMs: channelReceivedChunkTtlMs
|
|
716
722
|
})
|
|
717
723
|
const status = await receivedChunks.status(meta)
|
|
718
724
|
onChunk?.({
|
|
@@ -762,7 +768,8 @@ function createProcessor ({
|
|
|
762
768
|
routerPubkey: router.pubkey,
|
|
763
769
|
index,
|
|
764
770
|
total,
|
|
765
|
-
contentBytes: base64ToBytes(router.content)
|
|
771
|
+
contentBytes: base64ToBytes(router.content),
|
|
772
|
+
ttlMs: channelReceivedChunkTtlMs
|
|
766
773
|
})
|
|
767
774
|
const status = await receivedChunks.status(meta)
|
|
768
775
|
onChunk?.({
|
|
@@ -857,6 +864,9 @@ function createProcessor ({
|
|
|
857
864
|
onError?.(err)
|
|
858
865
|
}
|
|
859
866
|
}
|
|
867
|
+
|
|
868
|
+
processOuterEvent.close = () => receivedChunks.close()
|
|
869
|
+
return processOuterEvent
|
|
860
870
|
}
|
|
861
871
|
|
|
862
872
|
function shouldIgnoreGroupError (err) {
|
|
@@ -882,7 +892,7 @@ function shouldIgnoreGroupError (err) {
|
|
|
882
892
|
].includes(err?.message)
|
|
883
893
|
}
|
|
884
894
|
|
|
885
|
-
export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since, until, limit, mode = 'leecher', modeByPubkey, receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _getEvents = getEvents }) {
|
|
895
|
+
export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since, until, limit, mode = 'leecher', modeByPubkey, receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _getEvents = getEvents }) {
|
|
886
896
|
if (!relays?.length) throw new Error('NO_RELAYS')
|
|
887
897
|
const authors = privateChannelPubkeyList({ privateChannelPubkey, privateChannelPubkeys })
|
|
888
898
|
const filter = { kinds: [PRIVATE_BROADCAST_KIND] }
|
|
@@ -896,12 +906,16 @@ export async function fetch ({ receiverSigner, iykcSigner, privateChannelSigner
|
|
|
896
906
|
timeoutAfterFirstEose: null
|
|
897
907
|
})
|
|
898
908
|
events.sort((a, b) => a.created_at - b.created_at)
|
|
899
|
-
const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
|
|
900
|
-
|
|
901
|
-
|
|
909
|
+
const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
|
|
910
|
+
try {
|
|
911
|
+
for (const event of events) await processOuterEvent(event)
|
|
912
|
+
return events
|
|
913
|
+
} finally {
|
|
914
|
+
processOuterEvent.close()
|
|
915
|
+
}
|
|
902
916
|
}
|
|
903
917
|
|
|
904
|
-
export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since = nowSeconds() - 5, limit, liveOnly = false, mode = 'leecher', modeByPubkey, receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _liveEventsGenerator = getLiveEventsGenerator, _eventsFeedGenerator = getEventsFeedGenerator }) {
|
|
918
|
+
export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner = receiverSigner, privateChannelSignersByPubkey, privateChannelReaderSigner = privateChannelSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, privateChannelPubkey, privateChannelPubkeys, receiverPubkey, relays, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, since = nowSeconds() - 5, limit, liveOnly = false, mode = 'leecher', modeByPubkey, receivedChunkTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES, receivedChunkIndexedDB = globalThis.indexedDB, ignoredGroupTtlMs = DEFAULT_IGNORED_GROUP_TTL_MS, ignoredGroupMaxEntries = DEFAULT_IGNORED_GROUP_MAX_ENTRIES, _liveEventsGenerator = getLiveEventsGenerator, _eventsFeedGenerator = getEventsFeedGenerator }) {
|
|
905
919
|
if (!relays?.length) throw new Error('NO_RELAYS')
|
|
906
920
|
if (receiverSigner && !receiverSigner?.nip44DecryptDoubleDH && !receiverSigner?.nip44v3Decrypt) throw new Error('RECEIVER_SIGNER_NIP44V3_DECRYPT_UNSUPPORTED')
|
|
907
921
|
if (!privateChannelReaderSigner && !privateChannelReaderSignersByPubkey && !privateChannelSigner && !privateChannelSignersByPubkey) throw new Error('PRIVATE_CHANNEL_READER_REQUIRED')
|
|
@@ -910,17 +924,17 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
|
|
|
910
924
|
const filter = { kinds: [PRIVATE_BROADCAST_KIND], since }
|
|
911
925
|
if (authors.length) filter.authors = authors
|
|
912
926
|
if (limit != null) filter.limit = limit
|
|
913
|
-
const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
|
|
927
|
+
const processOuterEvent = createProcessor({ receiverSigner, iykcSigner, privateChannelSigner, privateChannelSignersByPubkey, privateChannelReaderSigner, privateChannelReaderSignersByPubkey, privateChannelReaderPubkey, privateChannelReaderPubkeysByPubkey, receiverPubkey, mode, modeByPubkey, onChunk, onEvent, onNymEvent, onSeedEvent, onContentKeyUsage, onError, receivedChunkTtlMs, receivedChunkTtlMsByPubkey, receivedChunkMaxBytes, receivedChunkIndexedDB, ignoredGroupTtlMs, ignoredGroupMaxEntries })
|
|
914
928
|
const controller = new AbortController()
|
|
915
929
|
const events = liveOnly
|
|
916
930
|
? _liveEventsGenerator(filter, relays, {
|
|
917
|
-
|
|
918
|
-
|
|
931
|
+
signal: controller.signal
|
|
932
|
+
})
|
|
919
933
|
: _eventsFeedGenerator(filter, relays, {
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
934
|
+
signal: controller.signal,
|
|
935
|
+
timeout: 5000,
|
|
936
|
+
timeoutAfterFirstEose: null
|
|
937
|
+
})
|
|
924
938
|
|
|
925
939
|
async function consumeEvents () {
|
|
926
940
|
try {
|
|
@@ -930,14 +944,17 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
|
|
|
930
944
|
}
|
|
931
945
|
} catch (error) {
|
|
932
946
|
if (!controller.signal.aborted && error?.message !== 'Aborted') onError?.(error)
|
|
947
|
+
} finally {
|
|
948
|
+
processOuterEvent.close()
|
|
933
949
|
}
|
|
934
950
|
}
|
|
935
951
|
|
|
936
|
-
consumeEvents()
|
|
952
|
+
const consumePromise = consumeEvents()
|
|
937
953
|
|
|
938
954
|
return {
|
|
939
955
|
close () {
|
|
940
956
|
controller.abort()
|
|
957
|
+
return consumePromise
|
|
941
958
|
}
|
|
942
959
|
}
|
|
943
960
|
}
|
|
@@ -5,7 +5,7 @@ export const DEFAULT_RECEIVED_CHUNK_TTL_MS = 60 * 60 * 1000 // 1 hour
|
|
|
5
5
|
export const DEFAULT_RECEIVED_CHUNK_MAX_BYTES = 16 * 1024 * 1024 // 16 MiB
|
|
6
6
|
|
|
7
7
|
const DEFAULT_PREFIX = 'libp2r2p:private-channel:received'
|
|
8
|
-
const DATABASE_VERSION =
|
|
8
|
+
const DATABASE_VERSION = 2
|
|
9
9
|
const GROUPS_STORE = 'groups'
|
|
10
10
|
const CHUNKS_STORE = 'chunks'
|
|
11
11
|
const STATE_STORE = 'state'
|
|
@@ -16,7 +16,7 @@ const decoder = new TextDecoder()
|
|
|
16
16
|
/*
|
|
17
17
|
IndexedDB schema, scoped per received-chunk prefix:
|
|
18
18
|
|
|
19
|
-
database `${prefix}:idb`, version
|
|
19
|
+
database `${prefix}:idb`, version 2
|
|
20
20
|
|
|
21
21
|
groups, keyPath "groupKey"
|
|
22
22
|
groupKey `${channelPubkey}:${routerPubkey}`
|
|
@@ -28,10 +28,12 @@ groups, keyPath "groupKey"
|
|
|
28
28
|
receiverPubkeys deduplicated intended receivers
|
|
29
29
|
byteSize logical bytes charged to this group
|
|
30
30
|
createdAt/updatedAt lifecycle timestamps in milliseconds
|
|
31
|
+
ttlMs/expiresAt group-specific retention and absolute expiry
|
|
31
32
|
drainToken/drainUntil optional drain lease owner and expiry
|
|
32
33
|
|
|
33
34
|
groups indexes
|
|
34
35
|
byUpdatedAt updatedAt
|
|
36
|
+
byExpiresAt expiresAt
|
|
35
37
|
|
|
36
38
|
chunks, keyPath ["groupKey", "index"]
|
|
37
39
|
groupKey owning group coordinate
|
|
@@ -80,6 +82,7 @@ function openDatabase (indexedDB, name) {
|
|
|
80
82
|
groups = tx.objectStore(GROUPS_STORE)
|
|
81
83
|
}
|
|
82
84
|
if (!groups.indexNames.contains('byUpdatedAt')) groups.createIndex('byUpdatedAt', 'updatedAt')
|
|
85
|
+
if (!groups.indexNames.contains('byExpiresAt')) groups.createIndex('byExpiresAt', 'expiresAt')
|
|
83
86
|
|
|
84
87
|
let chunks
|
|
85
88
|
if (!db.objectStoreNames.contains(CHUNKS_STORE)) {
|
|
@@ -119,12 +122,14 @@ function normalizeReceived (received) {
|
|
|
119
122
|
)
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
function normalizeMeta (meta) {
|
|
125
|
+
function normalizeMeta (meta, fallbackTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS) {
|
|
123
126
|
if (!meta || typeof meta !== 'object') return null
|
|
124
127
|
const total = Number(meta.total)
|
|
125
128
|
const nextIndex = Number(meta.nextIndex)
|
|
126
129
|
const rowIndex = Number(meta.rowIndex)
|
|
127
130
|
if (!meta.groupKey || !Number.isSafeInteger(total) || total < 1) return null
|
|
131
|
+
const updatedAt = Number(meta.updatedAt) || Date.now()
|
|
132
|
+
const ttlMs = Number.isFinite(meta.ttlMs) && meta.ttlMs >= 0 ? meta.ttlMs : fallbackTtlMs
|
|
128
133
|
return {
|
|
129
134
|
groupKey: String(meta.groupKey),
|
|
130
135
|
channelPubkey: String(meta.channelPubkey || ''),
|
|
@@ -139,7 +144,9 @@ function normalizeMeta (meta) {
|
|
|
139
144
|
receiverPubkeys: uniq(meta.receiverPubkeys),
|
|
140
145
|
byteSize: Math.max(0, Number(meta.byteSize) || 0),
|
|
141
146
|
createdAt: Number(meta.createdAt) || Date.now(),
|
|
142
|
-
updatedAt
|
|
147
|
+
updatedAt,
|
|
148
|
+
ttlMs,
|
|
149
|
+
expiresAt: Number(meta.expiresAt) || updatedAt + ttlMs,
|
|
143
150
|
drainToken: typeof meta.drainToken === 'string' ? meta.drainToken : '',
|
|
144
151
|
drainUntil: Math.max(0, Number(meta.drainUntil) || 0)
|
|
145
152
|
}
|
|
@@ -180,10 +187,18 @@ export function createReceivedChunkStore ({
|
|
|
180
187
|
const configuredMaxBytes = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : Infinity
|
|
181
188
|
let dbPromise
|
|
182
189
|
let readyPromise
|
|
190
|
+
let closed = false
|
|
183
191
|
|
|
184
192
|
function database () {
|
|
193
|
+
if (closed) return Promise.reject(new Error('RECEIVED_CHUNK_STORE_CLOSED'))
|
|
185
194
|
dbPromise ||= openDatabase(indexedDB, `${prefix}:idb`)
|
|
186
|
-
return dbPromise
|
|
195
|
+
return dbPromise.then(db => {
|
|
196
|
+
if (closed) {
|
|
197
|
+
db.close()
|
|
198
|
+
throw new Error('RECEIVED_CHUNK_STORE_CLOSED')
|
|
199
|
+
}
|
|
200
|
+
return db
|
|
201
|
+
})
|
|
187
202
|
}
|
|
188
203
|
|
|
189
204
|
async function transaction (storeNames, mode, work) {
|
|
@@ -215,7 +230,7 @@ export function createReceivedChunkStore ({
|
|
|
215
230
|
}
|
|
216
231
|
|
|
217
232
|
async function deleteGroupInTransaction (tx, groupKey, usage, knownMeta) {
|
|
218
|
-
const meta = knownMeta || normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
|
|
233
|
+
const meta = knownMeta || normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
219
234
|
const keys = (await run('getAllKeys', [globalThis.IDBKeyRange?.only?.(groupKey) ?? groupKey], CHUNKS_STORE, 'byGroup', { tx })).result
|
|
220
235
|
for (const key of keys) await run('delete', [key], CHUNKS_STORE, null, { tx })
|
|
221
236
|
await run('delete', [groupKey], GROUPS_STORE, null, { tx })
|
|
@@ -226,19 +241,18 @@ export function createReceivedChunkStore ({
|
|
|
226
241
|
async function oldestGroupsInTransaction (tx, except = '') {
|
|
227
242
|
const values = (await run('getAll', [], GROUPS_STORE, null, { tx })).result
|
|
228
243
|
return values
|
|
229
|
-
.map(normalizeMeta)
|
|
244
|
+
.map(value => normalizeMeta(value, configuredTtlMs))
|
|
230
245
|
.filter(meta => meta && meta.groupKey !== except)
|
|
231
246
|
.sort((left, right) => left.updatedAt - right.updatedAt || left.groupKey.localeCompare(right.groupKey))
|
|
232
247
|
}
|
|
233
248
|
|
|
234
249
|
async function cleanupStaleRaw (nowMs = Date.now()) {
|
|
235
|
-
const cutoff = nowMs - configuredTtlMs
|
|
236
250
|
return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
|
|
237
251
|
const usage = await readUsage(tx)
|
|
238
252
|
const groups = await oldestGroupsInTransaction(tx)
|
|
239
253
|
let removed = 0
|
|
240
254
|
for (const meta of groups) {
|
|
241
|
-
if (meta.
|
|
255
|
+
if (meta.expiresAt > nowMs && (!Number.isFinite(configuredMaxBytes) || usage.usedBytes <= configuredMaxBytes)) continue
|
|
242
256
|
await deleteGroupInTransaction(tx, meta.groupKey, usage, meta)
|
|
243
257
|
removed++
|
|
244
258
|
}
|
|
@@ -247,26 +261,26 @@ export function createReceivedChunkStore ({
|
|
|
247
261
|
})
|
|
248
262
|
}
|
|
249
263
|
|
|
250
|
-
function ready () {
|
|
251
|
-
readyPromise ||= cleanupStaleRaw()
|
|
264
|
+
function ready (nowMs = Date.now()) {
|
|
265
|
+
readyPromise ||= cleanupStaleRaw(nowMs)
|
|
252
266
|
return readyPromise
|
|
253
267
|
}
|
|
254
268
|
|
|
255
269
|
async function cleanupStale (nowMs = Date.now()) {
|
|
256
|
-
await ready()
|
|
270
|
+
await ready(nowMs)
|
|
257
271
|
return cleanupStaleRaw(nowMs)
|
|
258
272
|
}
|
|
259
273
|
|
|
260
|
-
async function putOnce ({ channelPubkey, routerPubkey, index, total, contentBytes }) {
|
|
274
|
+
async function putOnce ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs }) {
|
|
261
275
|
const groupKey = groupKeyFor(channelPubkey, routerPubkey)
|
|
262
276
|
const bytes = normalizeBytes(contentBytes)
|
|
263
277
|
const now = Date.now()
|
|
278
|
+
const groupTtlMs = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : configuredTtlMs
|
|
264
279
|
|
|
265
280
|
return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
|
|
266
281
|
const usage = await readUsage(tx)
|
|
267
|
-
let meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
|
|
268
|
-
|
|
269
|
-
if (meta && meta.updatedAt <= staleCutoff) {
|
|
282
|
+
let meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
283
|
+
if (meta && meta.expiresAt <= now) {
|
|
270
284
|
await deleteGroupInTransaction(tx, groupKey, usage, meta)
|
|
271
285
|
meta = null
|
|
272
286
|
}
|
|
@@ -289,8 +303,10 @@ export function createReceivedChunkStore ({
|
|
|
289
303
|
receiverPubkeys: [],
|
|
290
304
|
byteSize: 0,
|
|
291
305
|
createdAt: now,
|
|
292
|
-
updatedAt: now
|
|
293
|
-
|
|
306
|
+
updatedAt: now,
|
|
307
|
+
ttlMs: groupTtlMs,
|
|
308
|
+
expiresAt: now + groupTtlMs
|
|
309
|
+
}, configuredTtlMs)
|
|
294
310
|
}
|
|
295
311
|
|
|
296
312
|
const existing = (await run('get', [[groupKey, index]], CHUNKS_STORE, null, { tx })).result
|
|
@@ -304,11 +320,11 @@ export function createReceivedChunkStore ({
|
|
|
304
320
|
const requiredBytes = bytes.byteLength
|
|
305
321
|
const candidates = await oldestGroupsInTransaction(tx, groupKey)
|
|
306
322
|
for (const candidate of candidates) {
|
|
307
|
-
if (candidate.
|
|
323
|
+
if (candidate.expiresAt > now) continue
|
|
308
324
|
await deleteGroupInTransaction(tx, candidate.groupKey, usage, candidate)
|
|
309
325
|
}
|
|
310
326
|
for (const candidate of candidates) {
|
|
311
|
-
if (candidate.
|
|
327
|
+
if (candidate.expiresAt <= now) continue
|
|
312
328
|
if (!Number.isFinite(configuredMaxBytes) || usage.usedBytes + requiredBytes <= configuredMaxBytes) break
|
|
313
329
|
await deleteGroupInTransaction(tx, candidate.groupKey, usage, candidate)
|
|
314
330
|
}
|
|
@@ -327,6 +343,7 @@ export function createReceivedChunkStore ({
|
|
|
327
343
|
|
|
328
344
|
meta.total = total
|
|
329
345
|
meta.updatedAt = now
|
|
346
|
+
meta.expiresAt = now + meta.ttlMs
|
|
330
347
|
await run('put', [meta], GROUPS_STORE, null, { tx })
|
|
331
348
|
await writeUsage(tx, usage)
|
|
332
349
|
return { tooLarge: false, meta }
|
|
@@ -344,7 +361,7 @@ export function createReceivedChunkStore ({
|
|
|
344
361
|
})
|
|
345
362
|
}
|
|
346
363
|
|
|
347
|
-
async function put ({ channelPubkey, routerPubkey, index, total, contentBytes }) {
|
|
364
|
+
async function put ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs }) {
|
|
348
365
|
if (!channelPubkey || !routerPubkey) throw new Error('RECEIVED_CHUNK_GROUP_REQUIRED')
|
|
349
366
|
if (!Number.isSafeInteger(index) || !Number.isSafeInteger(total) || index < 0 || total < 1 || index >= total) {
|
|
350
367
|
throw new Error('INVALID_RECEIVED_CHUNK_INDEX')
|
|
@@ -353,7 +370,7 @@ export function createReceivedChunkStore ({
|
|
|
353
370
|
await ready()
|
|
354
371
|
while (true) {
|
|
355
372
|
try {
|
|
356
|
-
const result = await putOnce({ channelPubkey, routerPubkey, index, total, contentBytes: bytes })
|
|
373
|
+
const result = await putOnce({ channelPubkey, routerPubkey, index, total, contentBytes: bytes, ttlMs })
|
|
357
374
|
if (result.tooLarge) throw new Error('RECEIVED_CHUNK_GROUP_TOO_LARGE')
|
|
358
375
|
return result.meta
|
|
359
376
|
} catch (err) {
|
|
@@ -365,12 +382,12 @@ export function createReceivedChunkStore ({
|
|
|
365
382
|
async function readMeta (groupKey) {
|
|
366
383
|
await ready()
|
|
367
384
|
return transaction([GROUPS_STORE], 'readonly', async tx => {
|
|
368
|
-
return normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
|
|
385
|
+
return normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
369
386
|
})
|
|
370
387
|
}
|
|
371
388
|
|
|
372
389
|
async function status (metaOrGroupKey) {
|
|
373
|
-
const meta = typeof metaOrGroupKey === 'string' ? await readMeta(metaOrGroupKey) : normalizeMeta(metaOrGroupKey)
|
|
390
|
+
const meta = typeof metaOrGroupKey === 'string' ? await readMeta(metaOrGroupKey) : normalizeMeta(metaOrGroupKey, configuredTtlMs)
|
|
374
391
|
if (!meta) return { received: 0, missing: [] }
|
|
375
392
|
const missing = []
|
|
376
393
|
let received = 0
|
|
@@ -393,13 +410,15 @@ export function createReceivedChunkStore ({
|
|
|
393
410
|
while (true) {
|
|
394
411
|
const now = Date.now()
|
|
395
412
|
const result = await transaction([GROUPS_STORE], 'readwrite', async tx => {
|
|
396
|
-
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
|
|
413
|
+
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
397
414
|
if (!meta) return { meta: null, waitMs: 0 }
|
|
398
415
|
if (meta.drainToken && meta.drainToken !== token && meta.drainUntil > now) {
|
|
399
416
|
return { meta: null, waitMs: Math.min(DRAIN_LEASE_MS, meta.drainUntil - now) }
|
|
400
417
|
}
|
|
401
418
|
meta.drainToken = token
|
|
402
419
|
meta.drainUntil = now + DRAIN_LEASE_MS
|
|
420
|
+
meta.updatedAt = now
|
|
421
|
+
meta.expiresAt = now + meta.ttlMs
|
|
403
422
|
await run('put', [meta], GROUPS_STORE, null, { tx })
|
|
404
423
|
return { meta, waitMs: 0 }
|
|
405
424
|
})
|
|
@@ -410,7 +429,7 @@ export function createReceivedChunkStore ({
|
|
|
410
429
|
|
|
411
430
|
async function readDrainChunk (groupKey, token) {
|
|
412
431
|
return transaction([GROUPS_STORE, CHUNKS_STORE], 'readonly', async tx => {
|
|
413
|
-
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
|
|
432
|
+
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
414
433
|
if (!meta || meta.drainToken !== token) return { meta: null, bytes: null }
|
|
415
434
|
if (meta.nextIndex >= meta.total) return { meta, bytes: null }
|
|
416
435
|
const chunk = (await run('get', [[groupKey, meta.nextIndex]], CHUNKS_STORE, null, { tx })).result
|
|
@@ -420,7 +439,7 @@ export function createReceivedChunkStore ({
|
|
|
420
439
|
|
|
421
440
|
async function commitDrainMeta (nextMeta, token) {
|
|
422
441
|
return transaction([GROUPS_STORE], 'readwrite', async tx => {
|
|
423
|
-
const current = normalizeMeta((await run('get', [nextMeta.groupKey], GROUPS_STORE, null, { tx })).result)
|
|
442
|
+
const current = normalizeMeta((await run('get', [nextMeta.groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
424
443
|
if (!current || current.drainToken !== token) return null
|
|
425
444
|
current.nextIndex = nextMeta.nextIndex
|
|
426
445
|
current.rowIndex = nextMeta.rowIndex
|
|
@@ -428,6 +447,7 @@ export function createReceivedChunkStore ({
|
|
|
428
447
|
current.payloadCiphertext = nextMeta.payloadCiphertext
|
|
429
448
|
current.receiverPubkeys = uniq(nextMeta.receiverPubkeys)
|
|
430
449
|
current.updatedAt = nextMeta.updatedAt
|
|
450
|
+
current.expiresAt = current.updatedAt + current.ttlMs
|
|
431
451
|
current.drainUntil = Date.now() + DRAIN_LEASE_MS
|
|
432
452
|
await run('put', [current], GROUPS_STORE, null, { tx })
|
|
433
453
|
return current
|
|
@@ -436,7 +456,7 @@ export function createReceivedChunkStore ({
|
|
|
436
456
|
|
|
437
457
|
async function releaseDrain (groupKey, token) {
|
|
438
458
|
return transaction([GROUPS_STORE], 'readwrite', async tx => {
|
|
439
|
-
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
|
|
459
|
+
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
440
460
|
if (!meta || meta.drainToken !== token) return false
|
|
441
461
|
meta.drainToken = ''
|
|
442
462
|
meta.drainUntil = 0
|
|
@@ -504,7 +524,7 @@ export function createReceivedChunkStore ({
|
|
|
504
524
|
async function readChunkBytes (groupKey) {
|
|
505
525
|
await ready()
|
|
506
526
|
return transaction([GROUPS_STORE, CHUNKS_STORE], 'readonly', async tx => {
|
|
507
|
-
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result)
|
|
527
|
+
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
508
528
|
if (!meta) return { meta: null, chunks: [] }
|
|
509
529
|
const chunks = []
|
|
510
530
|
for (let index = 0; index < meta.total; index++) {
|
|
@@ -551,6 +571,12 @@ export function createReceivedChunkStore ({
|
|
|
551
571
|
})
|
|
552
572
|
}
|
|
553
573
|
|
|
574
|
+
function close () {
|
|
575
|
+
if (closed) return
|
|
576
|
+
closed = true
|
|
577
|
+
dbPromise?.then(db => db.close()).catch(() => {})
|
|
578
|
+
}
|
|
579
|
+
|
|
554
580
|
return {
|
|
555
581
|
cleanupStale,
|
|
556
582
|
drainAvailable,
|
|
@@ -559,7 +585,22 @@ export function createReceivedChunkStore ({
|
|
|
559
585
|
readChunkContents,
|
|
560
586
|
readEnvelopeBundleContent,
|
|
561
587
|
readEnvelopeBundleText,
|
|
588
|
+
ready,
|
|
562
589
|
removeGroup,
|
|
563
|
-
status
|
|
590
|
+
status,
|
|
591
|
+
close
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export async function cleanupReceivedChunkStorage ({
|
|
596
|
+
indexedDB = globalThis.indexedDB,
|
|
597
|
+
prefix = DEFAULT_PREFIX,
|
|
598
|
+
now = Date.now()
|
|
599
|
+
} = {}) {
|
|
600
|
+
const store = createReceivedChunkStore({ prefix, indexedDB })
|
|
601
|
+
try {
|
|
602
|
+
return await store.cleanupStale(now)
|
|
603
|
+
} finally {
|
|
604
|
+
store.close()
|
|
564
605
|
}
|
|
565
606
|
}
|