libp2r2p 0.5.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 +58 -11
- package/idb-queue/index.js +60 -11
- package/package.json +1 -1
- package/private-channel/index.js +49 -31
- package/private-channel/services/received-chunks.js +453 -245
- package/private-message/index.js +62 -17
- package/private-messenger/index.js +383 -76
- package/private-messenger/recovery/index.js +3 -1
- package/private-messenger/services/channel-state.js +132 -0
- package/private-messenger/services/storage-maintenance.js +324 -0
|
@@ -1,34 +1,118 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { bytesToBase64 } from '../../base64/index.js'
|
|
2
|
+
import { run } from '../../idb/index.js'
|
|
3
3
|
|
|
4
4
|
export const DEFAULT_RECEIVED_CHUNK_TTL_MS = 60 * 60 * 1000 // 1 hour
|
|
5
|
-
|
|
6
|
-
// 23 chunks (~0.65 MiB) to be sent if encrypted to 1000 participants.
|
|
7
|
-
export const DEFAULT_RECEIVED_CHUNK_MAX_BYTES = Math.min(JSONL_CHUNK_BYTES * 64, 3 * 1024 * 1024 /* 3 MiB cap */)
|
|
5
|
+
export const DEFAULT_RECEIVED_CHUNK_MAX_BYTES = 16 * 1024 * 1024 // 16 MiB
|
|
8
6
|
|
|
9
7
|
const DEFAULT_PREFIX = 'libp2r2p:private-channel:received'
|
|
10
|
-
const
|
|
8
|
+
const DATABASE_VERSION = 2
|
|
9
|
+
const GROUPS_STORE = 'groups'
|
|
10
|
+
const CHUNKS_STORE = 'chunks'
|
|
11
|
+
const STATE_STORE = 'state'
|
|
12
|
+
const USAGE_KEY = 'usage'
|
|
13
|
+
const DRAIN_LEASE_MS = 30_000
|
|
11
14
|
const decoder = new TextDecoder()
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
/*
|
|
17
|
+
IndexedDB schema, scoped per received-chunk prefix:
|
|
18
|
+
|
|
19
|
+
database `${prefix}:idb`, version 2
|
|
20
|
+
|
|
21
|
+
groups, keyPath "groupKey"
|
|
22
|
+
groupKey `${channelPubkey}:${routerPubkey}`
|
|
23
|
+
channelPubkey/routerPubkey private-channel coordinates
|
|
24
|
+
total expected chunk count
|
|
25
|
+
received/receivedCount sparse received-index map and its count
|
|
26
|
+
nextIndex/rowIndex/carry incremental payload decoding state
|
|
27
|
+
payloadCiphertext accumulated encrypted payload text
|
|
28
|
+
receiverPubkeys deduplicated intended receivers
|
|
29
|
+
byteSize logical bytes charged to this group
|
|
30
|
+
createdAt/updatedAt lifecycle timestamps in milliseconds
|
|
31
|
+
ttlMs/expiresAt group-specific retention and absolute expiry
|
|
32
|
+
drainToken/drainUntil optional drain lease owner and expiry
|
|
33
|
+
|
|
34
|
+
groups indexes
|
|
35
|
+
byUpdatedAt updatedAt
|
|
36
|
+
byExpiresAt expiresAt
|
|
37
|
+
|
|
38
|
+
chunks, keyPath ["groupKey", "index"]
|
|
39
|
+
groupKey owning group coordinate
|
|
40
|
+
index zero-based chunk position
|
|
41
|
+
bytes raw chunk bytes stored as Uint8Array
|
|
42
|
+
|
|
43
|
+
chunks indexes
|
|
44
|
+
byGroup groupKey
|
|
45
|
+
|
|
46
|
+
state, keyPath "key"
|
|
47
|
+
key state record name, currently "usage"
|
|
48
|
+
usedBytes total logical bytes held by all groups
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
function deferred () {
|
|
52
|
+
let resolve
|
|
53
|
+
let reject
|
|
54
|
+
const promise = new Promise((nextResolve, nextReject) => {
|
|
55
|
+
resolve = nextResolve
|
|
56
|
+
reject = nextReject
|
|
57
|
+
})
|
|
58
|
+
return { promise, resolve, reject }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function transactionDone (tx) {
|
|
62
|
+
const p = deferred()
|
|
63
|
+
tx.oncomplete = () => p.resolve()
|
|
64
|
+
tx.onabort = () => p.reject(tx.error || new Error('IDB_TRANSACTION_ABORTED'))
|
|
65
|
+
tx.onerror = () => p.reject(tx.error || new Error('IDB_TRANSACTION_FAILED'))
|
|
66
|
+
return p.promise
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function openDatabase (indexedDB, name) {
|
|
70
|
+
if (!indexedDB?.open) return Promise.reject(new Error('IDB_UNAVAILABLE'))
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const request = indexedDB.open(name, DATABASE_VERSION)
|
|
73
|
+
request.onerror = () => reject(request.error || new Error('IDB_OPEN_FAILED'))
|
|
74
|
+
request.onblocked = () => reject(new Error('IDB_DATABASE_BLOCKED'))
|
|
75
|
+
request.onupgradeneeded = () => {
|
|
76
|
+
const db = request.result
|
|
77
|
+
const tx = request.transaction
|
|
78
|
+
let groups
|
|
79
|
+
if (!db.objectStoreNames.contains(GROUPS_STORE)) {
|
|
80
|
+
groups = db.createObjectStore(GROUPS_STORE, { keyPath: 'groupKey' })
|
|
81
|
+
} else {
|
|
82
|
+
groups = tx.objectStore(GROUPS_STORE)
|
|
83
|
+
}
|
|
84
|
+
if (!groups.indexNames.contains('byUpdatedAt')) groups.createIndex('byUpdatedAt', 'updatedAt')
|
|
85
|
+
if (!groups.indexNames.contains('byExpiresAt')) groups.createIndex('byExpiresAt', 'expiresAt')
|
|
86
|
+
|
|
87
|
+
let chunks
|
|
88
|
+
if (!db.objectStoreNames.contains(CHUNKS_STORE)) {
|
|
89
|
+
chunks = db.createObjectStore(CHUNKS_STORE, { keyPath: ['groupKey', 'index'] })
|
|
90
|
+
} else {
|
|
91
|
+
chunks = tx.objectStore(CHUNKS_STORE)
|
|
92
|
+
}
|
|
93
|
+
if (!chunks.indexNames.contains('byGroup')) chunks.createIndex('byGroup', 'groupKey')
|
|
15
94
|
|
|
16
|
-
|
|
17
|
-
|
|
95
|
+
if (!db.objectStoreNames.contains(STATE_STORE)) db.createObjectStore(STATE_STORE, { keyPath: 'key' })
|
|
96
|
+
}
|
|
97
|
+
request.onsuccess = () => {
|
|
98
|
+
const db = request.result
|
|
99
|
+
db.onversionchange = () => db.close()
|
|
100
|
+
resolve(db)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
18
103
|
}
|
|
19
104
|
|
|
20
|
-
function
|
|
21
|
-
|
|
105
|
+
function normalizeBytes (value) {
|
|
106
|
+
if (value instanceof Uint8Array) return new Uint8Array(value)
|
|
107
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value)
|
|
108
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
|
109
|
+
throw new Error('RECEIVED_CHUNK_BYTES_REQUIRED')
|
|
22
110
|
}
|
|
23
111
|
|
|
24
112
|
function uniq (values) {
|
|
25
113
|
return [...new Set((values || []).filter(Boolean))]
|
|
26
114
|
}
|
|
27
115
|
|
|
28
|
-
function normalizeGroupKeys (value) {
|
|
29
|
-
return Array.isArray(value) ? uniq(value.filter(key => typeof key === 'string' && key)) : []
|
|
30
|
-
}
|
|
31
|
-
|
|
32
116
|
function normalizeReceived (received) {
|
|
33
117
|
if (!received || typeof received !== 'object' || Array.isArray(received)) return {}
|
|
34
118
|
return Object.fromEntries(
|
|
@@ -38,14 +122,16 @@ function normalizeReceived (received) {
|
|
|
38
122
|
)
|
|
39
123
|
}
|
|
40
124
|
|
|
41
|
-
function normalizeMeta (meta,
|
|
125
|
+
function normalizeMeta (meta, fallbackTtlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS) {
|
|
42
126
|
if (!meta || typeof meta !== 'object') return null
|
|
43
127
|
const total = Number(meta.total)
|
|
44
128
|
const nextIndex = Number(meta.nextIndex)
|
|
45
129
|
const rowIndex = Number(meta.rowIndex)
|
|
46
|
-
if (!Number.isSafeInteger(total) || total < 1) return null
|
|
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
|
|
47
133
|
return {
|
|
48
|
-
groupKey,
|
|
134
|
+
groupKey: String(meta.groupKey),
|
|
49
135
|
channelPubkey: String(meta.channelPubkey || ''),
|
|
50
136
|
routerPubkey: String(meta.routerPubkey || ''),
|
|
51
137
|
total,
|
|
@@ -55,10 +141,21 @@ function normalizeMeta (meta, groupKey) {
|
|
|
55
141
|
rowIndex: Number.isSafeInteger(rowIndex) && rowIndex >= 0 ? rowIndex : 0,
|
|
56
142
|
carry: typeof meta.carry === 'string' ? meta.carry : '',
|
|
57
143
|
payloadCiphertext: typeof meta.payloadCiphertext === 'string' ? meta.payloadCiphertext : '',
|
|
58
|
-
receiverPubkeys: uniq(meta.receiverPubkeys
|
|
144
|
+
receiverPubkeys: uniq(meta.receiverPubkeys),
|
|
59
145
|
byteSize: Math.max(0, Number(meta.byteSize) || 0),
|
|
60
146
|
createdAt: Number(meta.createdAt) || Date.now(),
|
|
61
|
-
updatedAt
|
|
147
|
+
updatedAt,
|
|
148
|
+
ttlMs,
|
|
149
|
+
expiresAt: Number(meta.expiresAt) || updatedAt + ttlMs,
|
|
150
|
+
drainToken: typeof meta.drainToken === 'string' ? meta.drainToken : '',
|
|
151
|
+
drainUntil: Math.max(0, Number(meta.drainUntil) || 0)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeUsage (value) {
|
|
156
|
+
return {
|
|
157
|
+
key: USAGE_KEY,
|
|
158
|
+
usedBytes: Number.isSafeInteger(value?.usedBytes) && value.usedBytes >= 0 ? value.usedBytes : 0
|
|
62
159
|
}
|
|
63
160
|
}
|
|
64
161
|
|
|
@@ -70,217 +167,228 @@ function isQuotaExceeded (err) {
|
|
|
70
167
|
/quota/i.test(err?.message || '')
|
|
71
168
|
}
|
|
72
169
|
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const bytes = base64ToBytes(part)
|
|
79
|
-
const joined = new Uint8Array(carry.length + bytes.length)
|
|
80
|
-
joined.set(carry)
|
|
81
|
-
joined.set(bytes, carry.length)
|
|
82
|
-
|
|
83
|
-
const completeLength = joined.length - (joined.length % 3)
|
|
84
|
-
if (completeLength) out += bytesToBase64(joined.slice(0, completeLength))
|
|
85
|
-
carry = joined.slice(completeLength)
|
|
86
|
-
}
|
|
170
|
+
function randomToken () {
|
|
171
|
+
const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16))
|
|
172
|
+
if (bytes) return [...bytes].map(value => value.toString(16).padStart(2, '0')).join('')
|
|
173
|
+
return `${Date.now().toString(16)}:${Math.random().toString(16).slice(2)}`
|
|
174
|
+
}
|
|
87
175
|
|
|
88
|
-
|
|
89
|
-
return
|
|
176
|
+
function wait (ms) {
|
|
177
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
90
178
|
}
|
|
91
179
|
|
|
92
180
|
export function createReceivedChunkStore ({
|
|
93
181
|
prefix = DEFAULT_PREFIX,
|
|
94
|
-
|
|
182
|
+
indexedDB = globalThis.indexedDB,
|
|
95
183
|
ttlMs = DEFAULT_RECEIVED_CHUNK_TTL_MS,
|
|
96
184
|
maxBytes = DEFAULT_RECEIVED_CHUNK_MAX_BYTES
|
|
97
185
|
} = {}) {
|
|
98
|
-
const groupsKey = `${prefix}:groups`
|
|
99
186
|
const configuredTtlMs = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : DEFAULT_RECEIVED_CHUNK_TTL_MS
|
|
100
187
|
const configuredMaxBytes = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : Infinity
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
188
|
+
let dbPromise
|
|
189
|
+
let readyPromise
|
|
190
|
+
let closed = false
|
|
191
|
+
|
|
192
|
+
function database () {
|
|
193
|
+
if (closed) return Promise.reject(new Error('RECEIVED_CHUNK_STORE_CLOSED'))
|
|
194
|
+
dbPromise ||= openDatabase(indexedDB, `${prefix}:idb`)
|
|
195
|
+
return dbPromise.then(db => {
|
|
196
|
+
if (closed) {
|
|
197
|
+
db.close()
|
|
198
|
+
throw new Error('RECEIVED_CHUNK_STORE_CLOSED')
|
|
199
|
+
}
|
|
200
|
+
return db
|
|
201
|
+
})
|
|
108
202
|
}
|
|
109
203
|
|
|
110
|
-
function
|
|
111
|
-
|
|
204
|
+
async function transaction (storeNames, mode, work) {
|
|
205
|
+
const db = await database()
|
|
206
|
+
const tx = db.transaction(storeNames, mode)
|
|
207
|
+
const done = transactionDone(tx)
|
|
208
|
+
try {
|
|
209
|
+
// `work` may await only IDB requests issued through `run` with this tx.
|
|
210
|
+
const result = await work(tx)
|
|
211
|
+
await done
|
|
212
|
+
return result
|
|
213
|
+
} catch (err) {
|
|
214
|
+
try { tx.abort() } catch {}
|
|
215
|
+
try { await done } catch {}
|
|
216
|
+
throw err
|
|
217
|
+
}
|
|
112
218
|
}
|
|
113
219
|
|
|
114
220
|
function groupKeyFor (channelPubkey, routerPubkey) {
|
|
115
221
|
return `${channelPubkey}:${routerPubkey}`
|
|
116
222
|
}
|
|
117
223
|
|
|
118
|
-
function
|
|
119
|
-
return
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function writeGroupKeys (keys) {
|
|
123
|
-
const normalized = normalizeGroupKeys(keys)
|
|
124
|
-
if (normalized.length) storage().setItem(groupsKey, JSON.stringify(normalized))
|
|
125
|
-
else storage().removeItem(groupsKey)
|
|
224
|
+
async function readUsage (tx) {
|
|
225
|
+
return normalizeUsage((await run('get', [USAGE_KEY], STATE_STORE, null, { tx })).result)
|
|
126
226
|
}
|
|
127
227
|
|
|
128
|
-
function
|
|
129
|
-
|
|
130
|
-
if (!keys.includes(groupKey)) writeGroupKeys(keys.concat(groupKey))
|
|
228
|
+
async function writeUsage (tx, usage) {
|
|
229
|
+
await run('put', [normalizeUsage(usage)], STATE_STORE, null, { tx })
|
|
131
230
|
}
|
|
132
231
|
|
|
133
|
-
function
|
|
134
|
-
|
|
232
|
+
async function deleteGroupInTransaction (tx, groupKey, usage, knownMeta) {
|
|
233
|
+
const meta = knownMeta || normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
234
|
+
const keys = (await run('getAllKeys', [globalThis.IDBKeyRange?.only?.(groupKey) ?? groupKey], CHUNKS_STORE, 'byGroup', { tx })).result
|
|
235
|
+
for (const key of keys) await run('delete', [key], CHUNKS_STORE, null, { tx })
|
|
236
|
+
await run('delete', [groupKey], GROUPS_STORE, null, { tx })
|
|
237
|
+
if (usage && meta) usage.usedBytes = Math.max(0, usage.usedBytes - meta.byteSize)
|
|
238
|
+
return Boolean(meta || keys.length)
|
|
135
239
|
}
|
|
136
240
|
|
|
137
|
-
function
|
|
138
|
-
|
|
241
|
+
async function oldestGroupsInTransaction (tx, except = '') {
|
|
242
|
+
const values = (await run('getAll', [], GROUPS_STORE, null, { tx })).result
|
|
243
|
+
return values
|
|
244
|
+
.map(value => normalizeMeta(value, configuredTtlMs))
|
|
245
|
+
.filter(meta => meta && meta.groupKey !== except)
|
|
246
|
+
.sort((left, right) => left.updatedAt - right.updatedAt || left.groupKey.localeCompare(right.groupKey))
|
|
139
247
|
}
|
|
140
248
|
|
|
141
|
-
function
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
249
|
+
async function cleanupStaleRaw (nowMs = Date.now()) {
|
|
250
|
+
return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
|
|
251
|
+
const usage = await readUsage(tx)
|
|
252
|
+
const groups = await oldestGroupsInTransaction(tx)
|
|
253
|
+
let removed = 0
|
|
254
|
+
for (const meta of groups) {
|
|
255
|
+
if (meta.expiresAt > nowMs && (!Number.isFinite(configuredMaxBytes) || usage.usedBytes <= configuredMaxBytes)) continue
|
|
256
|
+
await deleteGroupInTransaction(tx, meta.groupKey, usage, meta)
|
|
257
|
+
removed++
|
|
150
258
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function allMetas () {
|
|
157
|
-
const keys = readGroupKeys()
|
|
158
|
-
const metas = []
|
|
159
|
-
const liveKeys = []
|
|
160
|
-
for (const groupKey of keys) {
|
|
161
|
-
const meta = readMeta(groupKey)
|
|
162
|
-
if (!meta) continue
|
|
163
|
-
metas.push(meta)
|
|
164
|
-
liveKeys.push(groupKey)
|
|
165
|
-
}
|
|
166
|
-
if (liveKeys.length !== keys.length) writeGroupKeys(liveKeys)
|
|
167
|
-
return metas
|
|
259
|
+
await writeUsage(tx, usage)
|
|
260
|
+
return removed
|
|
261
|
+
})
|
|
168
262
|
}
|
|
169
263
|
|
|
170
|
-
function
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}, 0)
|
|
264
|
+
function ready (nowMs = Date.now()) {
|
|
265
|
+
readyPromise ||= cleanupStaleRaw(nowMs)
|
|
266
|
+
return readyPromise
|
|
174
267
|
}
|
|
175
268
|
|
|
176
|
-
function
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
.sort((a, b) => a.updatedAt - b.updatedAt)[0]?.groupKey || ''
|
|
269
|
+
async function cleanupStale (nowMs = Date.now()) {
|
|
270
|
+
await ready(nowMs)
|
|
271
|
+
return cleanupStaleRaw(nowMs)
|
|
180
272
|
}
|
|
181
273
|
|
|
182
|
-
function
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
274
|
+
async function putOnce ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs }) {
|
|
275
|
+
const groupKey = groupKeyFor(channelPubkey, routerPubkey)
|
|
276
|
+
const bytes = normalizeBytes(contentBytes)
|
|
277
|
+
const now = Date.now()
|
|
278
|
+
const groupTtlMs = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : configuredTtlMs
|
|
279
|
+
|
|
280
|
+
return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
|
|
281
|
+
const usage = await readUsage(tx)
|
|
282
|
+
let meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
283
|
+
if (meta && meta.expiresAt <= now) {
|
|
284
|
+
await deleteGroupInTransaction(tx, groupKey, usage, meta)
|
|
285
|
+
meta = null
|
|
286
|
+
}
|
|
287
|
+
if (meta && meta.total !== total) {
|
|
288
|
+
await deleteGroupInTransaction(tx, groupKey, usage, meta)
|
|
289
|
+
meta = null
|
|
290
|
+
}
|
|
291
|
+
if (!meta) {
|
|
292
|
+
meta = normalizeMeta({
|
|
293
|
+
groupKey,
|
|
294
|
+
channelPubkey,
|
|
295
|
+
routerPubkey,
|
|
296
|
+
total,
|
|
297
|
+
received: {},
|
|
298
|
+
receivedCount: 0,
|
|
299
|
+
nextIndex: 0,
|
|
300
|
+
rowIndex: 0,
|
|
301
|
+
carry: '',
|
|
302
|
+
payloadCiphertext: '',
|
|
303
|
+
receiverPubkeys: [],
|
|
304
|
+
byteSize: 0,
|
|
305
|
+
createdAt: now,
|
|
306
|
+
updatedAt: now,
|
|
307
|
+
ttlMs: groupTtlMs,
|
|
308
|
+
expiresAt: now + groupTtlMs
|
|
309
|
+
}, configuredTtlMs)
|
|
310
|
+
}
|
|
190
311
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
312
|
+
const existing = (await run('get', [[groupKey, index]], CHUNKS_STORE, null, { tx })).result
|
|
313
|
+
if (!existing) {
|
|
314
|
+
if (bytes.byteLength > configuredMaxBytes || meta.byteSize + bytes.byteLength > configuredMaxBytes) {
|
|
315
|
+
await deleteGroupInTransaction(tx, groupKey, usage, meta)
|
|
316
|
+
await writeUsage(tx, usage)
|
|
317
|
+
return { tooLarge: true, meta: null }
|
|
318
|
+
}
|
|
199
319
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
320
|
+
const requiredBytes = bytes.byteLength
|
|
321
|
+
const candidates = await oldestGroupsInTransaction(tx, groupKey)
|
|
322
|
+
for (const candidate of candidates) {
|
|
323
|
+
if (candidate.expiresAt > now) continue
|
|
324
|
+
await deleteGroupInTransaction(tx, candidate.groupKey, usage, candidate)
|
|
325
|
+
}
|
|
326
|
+
for (const candidate of candidates) {
|
|
327
|
+
if (candidate.expiresAt <= now) continue
|
|
328
|
+
if (!Number.isFinite(configuredMaxBytes) || usage.usedBytes + requiredBytes <= configuredMaxBytes) break
|
|
329
|
+
await deleteGroupInTransaction(tx, candidate.groupKey, usage, candidate)
|
|
330
|
+
}
|
|
331
|
+
if (Number.isFinite(configuredMaxBytes) && usage.usedBytes + requiredBytes > configuredMaxBytes) {
|
|
332
|
+
await deleteGroupInTransaction(tx, groupKey, usage, meta)
|
|
333
|
+
await writeUsage(tx, usage)
|
|
334
|
+
return { tooLarge: true, meta: null }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
await run('put', [{ groupKey, index, bytes }], CHUNKS_STORE, null, { tx })
|
|
338
|
+
meta.received[String(index)] = true
|
|
339
|
+
meta.receivedCount++
|
|
340
|
+
meta.byteSize += requiredBytes
|
|
341
|
+
usage.usedBytes += requiredBytes
|
|
208
342
|
}
|
|
209
|
-
groupKey = oldestGroupKey({ except })
|
|
210
|
-
}
|
|
211
343
|
|
|
212
|
-
|
|
344
|
+
meta.total = total
|
|
345
|
+
meta.updatedAt = now
|
|
346
|
+
meta.expiresAt = now + meta.ttlMs
|
|
347
|
+
await run('put', [meta], GROUPS_STORE, null, { tx })
|
|
348
|
+
await writeUsage(tx, usage)
|
|
349
|
+
return { tooLarge: false, meta }
|
|
350
|
+
})
|
|
213
351
|
}
|
|
214
352
|
|
|
215
|
-
function
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
353
|
+
async function evictOldestGroup (except = '') {
|
|
354
|
+
return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
|
|
355
|
+
const usage = await readUsage(tx)
|
|
356
|
+
const oldest = (await oldestGroupsInTransaction(tx, except))[0]
|
|
357
|
+
if (!oldest) return false
|
|
358
|
+
await deleteGroupInTransaction(tx, oldest.groupKey, usage, oldest)
|
|
359
|
+
await writeUsage(tx, usage)
|
|
360
|
+
return true
|
|
361
|
+
})
|
|
221
362
|
}
|
|
222
363
|
|
|
223
|
-
function put ({ channelPubkey, routerPubkey, index, total,
|
|
364
|
+
async function put ({ channelPubkey, routerPubkey, index, total, contentBytes, ttlMs }) {
|
|
224
365
|
if (!channelPubkey || !routerPubkey) throw new Error('RECEIVED_CHUNK_GROUP_REQUIRED')
|
|
225
366
|
if (!Number.isSafeInteger(index) || !Number.isSafeInteger(total) || index < 0 || total < 1 || index >= total) {
|
|
226
367
|
throw new Error('INVALID_RECEIVED_CHUNK_INDEX')
|
|
227
368
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (meta && meta.total !== total) {
|
|
238
|
-
removeGroup(groupKey)
|
|
239
|
-
meta = null
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (!meta) {
|
|
243
|
-
meta = normalizeMeta({
|
|
244
|
-
channelPubkey,
|
|
245
|
-
routerPubkey,
|
|
246
|
-
total,
|
|
247
|
-
received: {},
|
|
248
|
-
receivedCount: 0,
|
|
249
|
-
nextIndex: 0,
|
|
250
|
-
rowIndex: 0,
|
|
251
|
-
carry: '',
|
|
252
|
-
payloadCiphertext: '',
|
|
253
|
-
receiverPubkeys: [],
|
|
254
|
-
byteSize: 0,
|
|
255
|
-
createdAt: now,
|
|
256
|
-
updatedAt: now
|
|
257
|
-
}, groupKey)
|
|
258
|
-
addGroupKey(groupKey)
|
|
259
|
-
writeMeta(meta)
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
if (!meta.received[String(index)]) {
|
|
263
|
-
if (meta.byteSize + nextBytes > configuredMaxBytes) {
|
|
264
|
-
removeGroup(groupKey)
|
|
265
|
-
throw new Error('RECEIVED_CHUNK_GROUP_TOO_LARGE')
|
|
369
|
+
const bytes = normalizeBytes(contentBytes)
|
|
370
|
+
await ready()
|
|
371
|
+
while (true) {
|
|
372
|
+
try {
|
|
373
|
+
const result = await putOnce({ channelPubkey, routerPubkey, index, total, contentBytes: bytes, ttlMs })
|
|
374
|
+
if (result.tooLarge) throw new Error('RECEIVED_CHUNK_GROUP_TOO_LARGE')
|
|
375
|
+
return result.meta
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (!isQuotaExceeded(err) || !await evictOldestGroup(groupKeyFor(channelPubkey, routerPubkey))) throw err
|
|
266
378
|
}
|
|
267
|
-
writeChunk(chunkKey(groupKey, index), chunk, nextBytes, groupKey)
|
|
268
|
-
meta.received[String(index)] = true
|
|
269
|
-
meta.receivedCount++
|
|
270
|
-
meta.byteSize += nextBytes
|
|
271
379
|
}
|
|
380
|
+
}
|
|
272
381
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
382
|
+
async function readMeta (groupKey) {
|
|
383
|
+
await ready()
|
|
384
|
+
return transaction([GROUPS_STORE], 'readonly', async tx => {
|
|
385
|
+
return normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
386
|
+
})
|
|
278
387
|
}
|
|
279
388
|
|
|
280
|
-
function status (metaOrGroupKey) {
|
|
281
|
-
const meta = typeof metaOrGroupKey === 'string' ? readMeta(metaOrGroupKey) : metaOrGroupKey
|
|
389
|
+
async function status (metaOrGroupKey) {
|
|
390
|
+
const meta = typeof metaOrGroupKey === 'string' ? await readMeta(metaOrGroupKey) : normalizeMeta(metaOrGroupKey, configuredTtlMs)
|
|
282
391
|
if (!meta) return { received: 0, missing: [] }
|
|
283
|
-
|
|
284
392
|
const missing = []
|
|
285
393
|
let received = 0
|
|
286
394
|
for (let index = 0; index < meta.total; index++) {
|
|
@@ -291,98 +399,183 @@ export function createReceivedChunkStore ({
|
|
|
291
399
|
}
|
|
292
400
|
|
|
293
401
|
function rememberReceiverPubkey (meta, pubkey) {
|
|
294
|
-
if (
|
|
295
|
-
meta.receiverPubkeys.push(pubkey)
|
|
402
|
+
if (pubkey && !meta.receiverPubkeys.includes(pubkey)) meta.receiverPubkeys.push(pubkey)
|
|
296
403
|
}
|
|
297
404
|
|
|
298
405
|
function rememberPayloadCiphertext (meta, ciphertext) {
|
|
299
406
|
if (!meta.payloadCiphertext) meta.payloadCiphertext = ciphertext
|
|
300
407
|
}
|
|
301
408
|
|
|
302
|
-
async function
|
|
303
|
-
|
|
304
|
-
|
|
409
|
+
async function claimDrain (groupKey, token) {
|
|
410
|
+
while (true) {
|
|
411
|
+
const now = Date.now()
|
|
412
|
+
const result = await transaction([GROUPS_STORE], 'readwrite', async tx => {
|
|
413
|
+
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
414
|
+
if (!meta) return { meta: null, waitMs: 0 }
|
|
415
|
+
if (meta.drainToken && meta.drainToken !== token && meta.drainUntil > now) {
|
|
416
|
+
return { meta: null, waitMs: Math.min(DRAIN_LEASE_MS, meta.drainUntil - now) }
|
|
417
|
+
}
|
|
418
|
+
meta.drainToken = token
|
|
419
|
+
meta.drainUntil = now + DRAIN_LEASE_MS
|
|
420
|
+
meta.updatedAt = now
|
|
421
|
+
meta.expiresAt = now + meta.ttlMs
|
|
422
|
+
await run('put', [meta], GROUPS_STORE, null, { tx })
|
|
423
|
+
return { meta, waitMs: 0 }
|
|
424
|
+
})
|
|
425
|
+
if (!result.waitMs) return result.meta
|
|
426
|
+
await wait(result.waitMs)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function readDrainChunk (groupKey, token) {
|
|
431
|
+
return transaction([GROUPS_STORE, CHUNKS_STORE], 'readonly', async tx => {
|
|
432
|
+
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
433
|
+
if (!meta || meta.drainToken !== token) return { meta: null, bytes: null }
|
|
434
|
+
if (meta.nextIndex >= meta.total) return { meta, bytes: null }
|
|
435
|
+
const chunk = (await run('get', [[groupKey, meta.nextIndex]], CHUNKS_STORE, null, { tx })).result
|
|
436
|
+
return { meta, bytes: chunk ? normalizeBytes(chunk.bytes) : null }
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function commitDrainMeta (nextMeta, token) {
|
|
441
|
+
return transaction([GROUPS_STORE], 'readwrite', async tx => {
|
|
442
|
+
const current = normalizeMeta((await run('get', [nextMeta.groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
443
|
+
if (!current || current.drainToken !== token) return null
|
|
444
|
+
current.nextIndex = nextMeta.nextIndex
|
|
445
|
+
current.rowIndex = nextMeta.rowIndex
|
|
446
|
+
current.carry = nextMeta.carry
|
|
447
|
+
current.payloadCiphertext = nextMeta.payloadCiphertext
|
|
448
|
+
current.receiverPubkeys = uniq(nextMeta.receiverPubkeys)
|
|
449
|
+
current.updatedAt = nextMeta.updatedAt
|
|
450
|
+
current.expiresAt = current.updatedAt + current.ttlMs
|
|
451
|
+
current.drainUntil = Date.now() + DRAIN_LEASE_MS
|
|
452
|
+
await run('put', [current], GROUPS_STORE, null, { tx })
|
|
453
|
+
return current
|
|
454
|
+
})
|
|
455
|
+
}
|
|
305
456
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
457
|
+
async function releaseDrain (groupKey, token) {
|
|
458
|
+
return transaction([GROUPS_STORE], 'readwrite', async tx => {
|
|
459
|
+
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
460
|
+
if (!meta || meta.drainToken !== token) return false
|
|
461
|
+
meta.drainToken = ''
|
|
462
|
+
meta.drainUntil = 0
|
|
463
|
+
await run('put', [meta], GROUPS_STORE, null, { tx })
|
|
464
|
+
return true
|
|
465
|
+
})
|
|
466
|
+
}
|
|
310
467
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
468
|
+
async function drainAvailable (groupKey, { onLine } = {}) {
|
|
469
|
+
await ready()
|
|
470
|
+
const token = randomToken()
|
|
471
|
+
let meta = await claimDrain(groupKey, token)
|
|
472
|
+
if (!meta) return { complete: false, stopped: false, meta: null }
|
|
473
|
+
try {
|
|
474
|
+
while (meta.nextIndex < meta.total) {
|
|
475
|
+
const snapshot = await readDrainChunk(groupKey, token)
|
|
476
|
+
meta = snapshot.meta
|
|
477
|
+
if (!meta || !snapshot.bytes) break
|
|
478
|
+
|
|
479
|
+
const text = `${meta.carry}${decoder.decode(snapshot.bytes)}`
|
|
480
|
+
let start = 0
|
|
481
|
+
let end = text.indexOf('\n', start)
|
|
482
|
+
while (end !== -1) {
|
|
483
|
+
const line = text.slice(start, end)
|
|
484
|
+
start = end + 1
|
|
485
|
+
if (line) {
|
|
486
|
+
const result = await onLine?.(line, meta.rowIndex, meta, { rememberPayloadCiphertext, rememberReceiverPubkey })
|
|
487
|
+
meta.rowIndex++
|
|
488
|
+
if (result?.stop) {
|
|
489
|
+
meta.updatedAt = Date.now()
|
|
490
|
+
meta = await commitDrainMeta(meta, token)
|
|
491
|
+
return { complete: false, stopped: true, meta }
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
end = text.indexOf('\n', start)
|
|
495
|
+
}
|
|
496
|
+
meta.carry = text.slice(start)
|
|
497
|
+
meta.nextIndex++
|
|
498
|
+
meta.updatedAt = Date.now()
|
|
499
|
+
meta = await commitDrainMeta(meta, token)
|
|
500
|
+
if (!meta) break
|
|
501
|
+
}
|
|
314
502
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (line) {
|
|
319
|
-
const result = await onLine?.(line, meta.rowIndex, meta, { rememberPayloadCiphertext, rememberReceiverPubkey })
|
|
503
|
+
if (meta && meta.nextIndex >= meta.total) {
|
|
504
|
+
if (meta.carry) {
|
|
505
|
+
const result = await onLine?.(meta.carry, meta.rowIndex, meta, { rememberPayloadCiphertext, rememberReceiverPubkey })
|
|
320
506
|
meta.rowIndex++
|
|
507
|
+
meta.carry = ''
|
|
321
508
|
if (result?.stop) {
|
|
322
509
|
meta.updatedAt = Date.now()
|
|
323
|
-
|
|
510
|
+
meta = await commitDrainMeta(meta, token)
|
|
324
511
|
return { complete: false, stopped: true, meta }
|
|
325
512
|
}
|
|
326
513
|
}
|
|
327
|
-
|
|
514
|
+
meta.updatedAt = Date.now()
|
|
515
|
+
meta = await commitDrainMeta(meta, token)
|
|
516
|
+
return { complete: Boolean(meta), stopped: false, meta }
|
|
328
517
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
meta.nextIndex++
|
|
333
|
-
meta.updatedAt = Date.now()
|
|
334
|
-
writeMeta(meta)
|
|
518
|
+
return { complete: false, stopped: false, meta }
|
|
519
|
+
} finally {
|
|
520
|
+
await releaseDrain(groupKey, token).catch(() => {})
|
|
335
521
|
}
|
|
522
|
+
}
|
|
336
523
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
524
|
+
async function readChunkBytes (groupKey) {
|
|
525
|
+
await ready()
|
|
526
|
+
return transaction([GROUPS_STORE, CHUNKS_STORE], 'readonly', async tx => {
|
|
527
|
+
const meta = normalizeMeta((await run('get', [groupKey], GROUPS_STORE, null, { tx })).result, configuredTtlMs)
|
|
528
|
+
if (!meta) return { meta: null, chunks: [] }
|
|
529
|
+
const chunks = []
|
|
530
|
+
for (let index = 0; index < meta.total; index++) {
|
|
531
|
+
const chunk = (await run('get', [[groupKey, index]], CHUNKS_STORE, null, { tx })).result
|
|
532
|
+
if (!chunk) throw new Error('RECEIVED_CHUNK_MISSING')
|
|
533
|
+
chunks.push(normalizeBytes(chunk.bytes))
|
|
347
534
|
}
|
|
348
|
-
meta
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
535
|
+
return { meta, chunks }
|
|
536
|
+
})
|
|
537
|
+
}
|
|
352
538
|
|
|
353
|
-
|
|
539
|
+
async function readChunkContents (groupKey) {
|
|
540
|
+
return (await readChunkBytes(groupKey)).chunks.map(bytes => decoder.decode(bytes))
|
|
354
541
|
}
|
|
355
542
|
|
|
356
|
-
function readEnvelopeBundleContent (groupKey) {
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
543
|
+
async function readEnvelopeBundleContent (groupKey) {
|
|
544
|
+
const { chunks } = await readChunkBytes(groupKey)
|
|
545
|
+
return bytesToBase64(joinChunks(chunks))
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function joinChunks (chunks) {
|
|
549
|
+
let length = 0
|
|
550
|
+
for (const chunk of chunks) length += chunk.byteLength
|
|
551
|
+
const joined = new Uint8Array(length)
|
|
552
|
+
let offset = 0
|
|
553
|
+
for (const chunk of chunks) {
|
|
554
|
+
joined.set(chunk, offset)
|
|
555
|
+
offset += chunk.byteLength
|
|
364
556
|
}
|
|
365
|
-
return
|
|
557
|
+
return joined
|
|
366
558
|
}
|
|
367
559
|
|
|
368
|
-
function readEnvelopeBundleText (groupKey) {
|
|
369
|
-
|
|
370
|
-
return decoder.decode(base64ToBytes(content))
|
|
560
|
+
async function readEnvelopeBundleText (groupKey) {
|
|
561
|
+
return decoder.decode(joinChunks((await readChunkBytes(groupKey)).chunks))
|
|
371
562
|
}
|
|
372
563
|
|
|
373
|
-
function
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
return parts
|
|
564
|
+
async function removeGroup (groupKey) {
|
|
565
|
+
await ready()
|
|
566
|
+
return transaction([GROUPS_STORE, CHUNKS_STORE, STATE_STORE], 'readwrite', async tx => {
|
|
567
|
+
const usage = await readUsage(tx)
|
|
568
|
+
const removed = await deleteGroupInTransaction(tx, groupKey, usage)
|
|
569
|
+
await writeUsage(tx, usage)
|
|
570
|
+
return removed
|
|
571
|
+
})
|
|
383
572
|
}
|
|
384
573
|
|
|
385
|
-
|
|
574
|
+
function close () {
|
|
575
|
+
if (closed) return
|
|
576
|
+
closed = true
|
|
577
|
+
dbPromise?.then(db => db.close()).catch(() => {})
|
|
578
|
+
}
|
|
386
579
|
|
|
387
580
|
return {
|
|
388
581
|
cleanupStale,
|
|
@@ -392,7 +585,22 @@ export function createReceivedChunkStore ({
|
|
|
392
585
|
readChunkContents,
|
|
393
586
|
readEnvelopeBundleContent,
|
|
394
587
|
readEnvelopeBundleText,
|
|
588
|
+
ready,
|
|
395
589
|
removeGroup,
|
|
396
|
-
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()
|
|
397
605
|
}
|
|
398
606
|
}
|