libp2r2p 0.11.6 → 0.11.8
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 +57 -1
- package/package.json +1 -1
- package/private-channel/helpers/chunks.js +28 -23
- package/private-channel/helpers/subscription-error.js +1 -1
- package/private-channel/index.js +56 -53
- package/private-message/index.js +126 -10
- package/private-messenger/helpers/recovery-retry.js +26 -0
- package/private-messenger/index.js +85 -3
- package/private-messenger/recovery/index.js +21 -8
- package/relay/helpers/error.js +12 -0
- package/relay/helpers/live-buffer-error.js +15 -0
- package/relay/services/relay-connection.js +69 -20
- package/relay/services/relay-pool.js +24 -14
- package/temporary-storage/index.js +10 -3
package/README.md
CHANGED
|
@@ -339,7 +339,7 @@ must not delete a kind `3560` event, whether or not that event has an `s` tag.
|
|
|
339
339
|
### Storage Maintenance
|
|
340
340
|
|
|
341
341
|
While an outgoing private message is being assembled, the messenger keeps
|
|
342
|
-
encrypted envelope rows
|
|
342
|
+
encrypted envelope rows in `sessionStorage` (router fragments are encoded on demand, without a second stored copy). They are
|
|
343
343
|
removed when the send finishes, but an interrupted browser operation can leave
|
|
344
344
|
them behind until cleanup runs.
|
|
345
345
|
|
|
@@ -965,3 +965,59 @@ latency, and short-lived channel/recipient preferences avoid recently refused
|
|
|
965
965
|
relays without changing receive subscriptions. See the
|
|
966
966
|
[session coordinator](private-messenger/session/README.md) for deferred offline
|
|
967
967
|
attempts and final failure notifications.
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
### Relay backpressure and bounded synchronization (0.11.7)
|
|
971
|
+
|
|
972
|
+
Optional numeric `retry_after` seconds on relay `CLOSED` and rejected `OK`
|
|
973
|
+
responses are preserved as `retryAfterMs` and `retryAt` on native relay errors.
|
|
974
|
+
Only `rate-limited:` responses apply this advice; invalid values are ignored
|
|
975
|
+
and delays are capped at five minutes. New operations on that connection wait
|
|
976
|
+
within their existing deadlines; active subscriptions remain live and `CLOSE`
|
|
977
|
+
never waits. Cancelled or timed-out operations cannot be sent later. A rejection
|
|
978
|
+
is still reported to the caller; this does not silently republish failed events.
|
|
979
|
+
|
|
980
|
+
`createEventReplyPacker` and `createMissingMessageReplyPacker` accept
|
|
981
|
+
`bytesPerChunk` (default 128 KiB of UTF-8 JSONL), in addition to the existing
|
|
982
|
+
100-record maximum. One indivisible oversized record is sent alone. Consumers
|
|
983
|
+
must keep failed requests pending; no record is truncated or discarded. Final
|
|
984
|
+
publication failures retain the payload/index for retry. Outbound channel
|
|
985
|
+
fragmentation streams from prepared encrypted rows, cleans partial writes and
|
|
986
|
+
removes failed new keys from the temporary-storage registry. It does not raise
|
|
987
|
+
the browser's Web Storage quota or delete unrelated application data.
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
### Private subscription recovery (0.11.8)
|
|
991
|
+
|
|
992
|
+
`privateChannel.subscribe()` returns `{ ready, done, close }`. `done` always
|
|
993
|
+
resolves to `{ status: 'closed' | 'ended' | 'failed', error? }` after processing
|
|
994
|
+
stops. Explicit cancellation is `closed`; a naturally exhausted stream is
|
|
995
|
+
`ended`; a terminal error is `failed` and still reaches `onError`.
|
|
996
|
+
|
|
997
|
+
`private-message.watch()` accepts an optional asynchronous
|
|
998
|
+
`onSubscriptionState({ state, relay, since, until?, error? })` callback.
|
|
999
|
+
`interrupted` identifies the conservative lower recovery boundary; after a
|
|
1000
|
+
buffer overflow, every affected channel must finish this callback before its
|
|
1001
|
+
shared relay subscription reopens. `ready` follows successful initial relay
|
|
1002
|
+
readiness on the replacement. Callbacks and completions are generation-fenced.
|
|
1003
|
+
Equal watch settings cannot keep a dead subscription registered as active.
|
|
1004
|
+
|
|
1005
|
+
Overflow recovery keeps one timer per relay with exponential 1–30 second
|
|
1006
|
+
backoff and 20% jitter, reset after a stable minute. Other relays remain live.
|
|
1007
|
+
Removal, pause and close cancel pending work. Fragment storage stays scoped to
|
|
1008
|
+
the same session across replacement subscriptions. Permanent refusals remain
|
|
1009
|
+
reported and do not trigger this automatic retry loop.
|
|
1010
|
+
|
|
1011
|
+
PrivateMessenger persists each interrupted interval before reopening, then
|
|
1012
|
+
uses paged `fetchHistory` to repair the gap while listening for new messages.
|
|
1013
|
+
Transient history failures retry with bounded backoff and honor `retryAt`;
|
|
1014
|
+
dense-page limits, permanent refusals and unknown failures stay pending without
|
|
1015
|
+
an automatic loop. Disabled historical recovery restores only live input.
|
|
1016
|
+
Existing persisted ranges, checkpoints, recovery windows and ack/nack semantics
|
|
1017
|
+
are unchanged; newer live arrivals cannot erase a pending interval.
|
|
1018
|
+
|
|
1019
|
+
`RELAY_LIVE_BUFFER_FULL` retains `relay`, `phase`, `recoverySince` and a bounded
|
|
1020
|
+
`buffer` diagnostic: stage (`delivery`, `reconnect`, `history-wait`), queued
|
|
1021
|
+
count/bytes, incoming bytes, limits and oldest queued age. Queue bytes are UTF-8
|
|
1022
|
+
serialized volume, not heap use. Diagnostics never retain event contents or
|
|
1023
|
+
filters. Existing 1,000-item / 8-MiB live limits are unchanged.
|
package/package.json
CHANGED
|
@@ -78,23 +78,6 @@ function encryptedPayload ({ messageSecretKey, event }) {
|
|
|
78
78
|
return nip44v3.encrypt(messageSecretKey, messagePubkey, ROUTER_KIND, '', JSON.stringify(event))
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
function appendLine (chunk, line, id, chunkIndex, temporaryStorage) {
|
|
82
|
-
while (line.length) {
|
|
83
|
-
const available = JSONL_CHUNK_BYTES - chunk.length
|
|
84
|
-
chunk = appendBytes(chunk, line.slice(0, available))
|
|
85
|
-
line = line.slice(available)
|
|
86
|
-
if (chunk.length === JSONL_CHUNK_BYTES) {
|
|
87
|
-
temporaryStorage.setItem(tempKey(id, chunkIndex++), bytesToBase64(chunk))
|
|
88
|
-
chunk = new Uint8Array()
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return { chunk, chunkIndex }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function appendRow (chunk, row, id, chunkIndex, temporaryStorage) {
|
|
95
|
-
return appendLine(chunk, encoder.encode(`${row}\n`), id, chunkIndex, temporaryStorage)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
81
|
function joinByteChunks (parts) {
|
|
99
82
|
let length = 0
|
|
100
83
|
const decoded = parts.map(part => {
|
|
@@ -249,20 +232,42 @@ export function preparedRowIndexesForReceivers (preparedRows, receivers) {
|
|
|
249
232
|
return indexes
|
|
250
233
|
}
|
|
251
234
|
|
|
235
|
+
// Keep only one encoded row and one fragment in memory; do not stage a
|
|
236
|
+
// second, Base64-expanded copy of the entire message in Web Storage.
|
|
237
|
+
export function preparedChunkCount (preparedRows, rowIndexes = preparedRows.rowIndexes) {
|
|
238
|
+
let bytes = 0
|
|
239
|
+
for (const index of [0, ...rowIndexes]) bytes += encoder.encode(readPreparedRow(preparedRows, index)).length + 1
|
|
240
|
+
return Math.max(1, Math.ceil(bytes / JSONL_CHUNK_BYTES))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function * preparedChunks (preparedRows, rowIndexes = preparedRows.rowIndexes) {
|
|
244
|
+
let chunk = new Uint8Array()
|
|
245
|
+
for (const index of [0, ...rowIndexes]) {
|
|
246
|
+
const row = encoder.encode(`${readPreparedRow(preparedRows, index)}\n`)
|
|
247
|
+
for (let offset = 0; offset < row.length;) {
|
|
248
|
+
const size = Math.min(JSONL_CHUNK_BYTES - chunk.length, row.length - offset)
|
|
249
|
+
chunk = appendBytes(chunk, row.subarray(offset, offset + size))
|
|
250
|
+
offset += size
|
|
251
|
+
if (chunk.length === JSONL_CHUNK_BYTES) {
|
|
252
|
+
yield bytesToBase64(chunk)
|
|
253
|
+
chunk = new Uint8Array()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (chunk.length) yield bytesToBase64(chunk)
|
|
258
|
+
}
|
|
259
|
+
|
|
252
260
|
export function writeChunksFromPreparedRows (preparedRows, rowIndexes = preparedRows?.rowIndexes || []) {
|
|
253
261
|
const temporaryStorage = storageFor(preparedRows?.temporaryStorage)
|
|
254
262
|
const id = temporaryId()
|
|
255
|
-
let chunk = new Uint8Array()
|
|
256
263
|
let chunkIndex = 0
|
|
257
264
|
try {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
;({ chunk, chunkIndex } = appendRow(chunk, readPreparedRow(preparedRows, rowIndex), id, chunkIndex, temporaryStorage))
|
|
265
|
+
for (const content of preparedChunks(preparedRows, rowIndexes)) {
|
|
266
|
+
temporaryStorage.setItem(tempKey(id, chunkIndex++), content)
|
|
261
267
|
}
|
|
262
|
-
if (chunk.length || chunkIndex === 0) temporaryStorage.setItem(tempKey(id, chunkIndex++), bytesToBase64(chunk))
|
|
263
268
|
return { id, total: chunkIndex, ownContentPubkey: preparedRows.ownContentPubkey || '' }
|
|
264
269
|
} catch (err) {
|
|
265
|
-
cleanupChunks(id, chunkIndex
|
|
270
|
+
cleanupChunks(id, chunkIndex, temporaryStorage)
|
|
266
271
|
throw err
|
|
267
272
|
}
|
|
268
273
|
}
|
|
@@ -5,7 +5,7 @@ export function subscriptionError (reason, relay) {
|
|
|
5
5
|
const error = reason instanceof AggregateError
|
|
6
6
|
? new AggregateError(reason.errors, message, { cause: reason })
|
|
7
7
|
: new Error(message, { cause: reason })
|
|
8
|
-
for (const key of ['name', 'code', 'category', 'closeCode', 'closeReason', 'wasClean']) {
|
|
8
|
+
for (const key of ['name', 'code', 'category', 'retryAfterMs', 'retryAt', 'closeCode', 'closeReason', 'wasClean', 'phase', 'buffer', 'recoverySince']) {
|
|
9
9
|
if (reason?.[key] !== undefined) error[key] = reason[key]
|
|
10
10
|
}
|
|
11
11
|
error.operation = 'private-channel.subscribe'
|
package/private-channel/index.js
CHANGED
|
@@ -13,16 +13,15 @@ import * as nip44v3 from '../nip44-v3/index.js'
|
|
|
13
13
|
import { relayPool } from '../relay/index.js'
|
|
14
14
|
import { JSONL_CHUNK_BYTES, NYM_CARRIER_CHUNK_CHARS } from './helpers/chunk-size.js'
|
|
15
15
|
import {
|
|
16
|
-
cleanupChunks,
|
|
17
16
|
cleanupEnvelopeRows,
|
|
18
17
|
decodeChunkLines,
|
|
19
18
|
prepareEnvelopeRows,
|
|
20
19
|
preparedRowIndexesForReceivers,
|
|
21
|
-
readChunkContent,
|
|
22
20
|
readPreparedRow,
|
|
23
21
|
receiverPubkeys,
|
|
24
22
|
receiverPubkeysWithoutContentKeys,
|
|
25
|
-
|
|
23
|
+
preparedChunkCount,
|
|
24
|
+
preparedChunks
|
|
26
25
|
} from './helpers/chunks.js'
|
|
27
26
|
import { EXPIRATION_SECONDS, MAX_EVENT_BYTES, NYM_CARRIER_KIND, PRIVATE_BROADCAST_KIND, ROUTER_KIND } from './constants/index.js'
|
|
28
27
|
import { eventByteLength, hasImkcTag, makeNymCarrierEvent, makeRouterEvent, nowSeconds, readChunkTag, readFileChunkIndex, readIdTag, readImkcProof, readImkcTag, readReceiverTag, readSenderTag } from './helpers/event.js'
|
|
@@ -121,15 +120,20 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
|
|
|
121
120
|
rowScope: channelPubkey,
|
|
122
121
|
temporaryStorageArea
|
|
123
122
|
})
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
123
|
+
try {
|
|
124
|
+
const imkcPubkey = preparedRows.ownContentPubkey || ''
|
|
125
|
+
const imkcProof = imkcPubkey ? await makeImkcProof({ senderSigner, senderPubkey, imkcPubkey }) : ''
|
|
126
|
+
return {
|
|
127
|
+
senderPubkey,
|
|
128
|
+
channelPubkey,
|
|
129
|
+
channelReaderPubkey,
|
|
130
|
+
preparedRows,
|
|
131
|
+
imkcPubkey,
|
|
132
|
+
imkcProof
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
cleanupEnvelopeRows(preparedRows)
|
|
136
|
+
throw error
|
|
133
137
|
}
|
|
134
138
|
}
|
|
135
139
|
|
|
@@ -139,45 +143,36 @@ async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiver
|
|
|
139
143
|
const receiverPubkeyList = receiverPubkeys(receivers)
|
|
140
144
|
const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
|
|
141
145
|
const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
if (onPreparedSeed) {
|
|
150
|
-
const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
|
|
151
|
-
const payloadRow = readPreparedRow(context.preparedRows, 0)
|
|
152
|
-
for (const rowIndex of rowIndexes) {
|
|
153
|
-
await onPreparedSeed({ channelPubkey: context.channelPubkey, router, jsonl: `${payloadRow}\n${readPreparedRow(context.preparedRows, rowIndex)}\n` })
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
for (let index = 0; index < total; index++) {
|
|
157
|
-
const content = readChunkContent(id, index, temporaryStorage)
|
|
158
|
-
const router = finalizeEvent(makeRouterEvent({
|
|
159
|
-
pubkey: routerPubkey,
|
|
160
|
-
senderPubkey: context.senderPubkey,
|
|
161
|
-
imkcPubkey: context.imkcPubkey,
|
|
162
|
-
imkcProof: context.imkcProof,
|
|
163
|
-
receiverPubkey: routerReceiverTag,
|
|
164
|
-
fileChunkIndex,
|
|
165
|
-
chunkIndex: index,
|
|
166
|
-
chunkTotal: total,
|
|
167
|
-
content
|
|
168
|
-
}), routerSeckey)
|
|
169
|
-
const createdAt = nowSeconds()
|
|
170
|
-
const outer = await privateChannelSigner.signEvent({
|
|
171
|
-
kind: PRIVATE_BROADCAST_KIND,
|
|
172
|
-
created_at: createdAt,
|
|
173
|
-
tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
|
|
174
|
-
content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
|
|
175
|
-
})
|
|
176
|
-
if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
|
|
177
|
-
yield outer
|
|
146
|
+
const total = preparedChunkCount(context.preparedRows, rowIndexes)
|
|
147
|
+
if (onPreparedSeed) {
|
|
148
|
+
const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
|
|
149
|
+
const payloadRow = readPreparedRow(context.preparedRows, 0)
|
|
150
|
+
for (const rowIndex of rowIndexes) {
|
|
151
|
+
await onPreparedSeed({ channelPubkey: context.channelPubkey, router, jsonl: `${payloadRow}\n${readPreparedRow(context.preparedRows, rowIndex)}\n` })
|
|
178
152
|
}
|
|
179
|
-
}
|
|
180
|
-
|
|
153
|
+
}
|
|
154
|
+
let index = 0
|
|
155
|
+
for (const content of preparedChunks(context.preparedRows, rowIndexes)) {
|
|
156
|
+
const router = finalizeEvent(makeRouterEvent({
|
|
157
|
+
pubkey: routerPubkey,
|
|
158
|
+
senderPubkey: context.senderPubkey,
|
|
159
|
+
imkcPubkey: context.imkcPubkey,
|
|
160
|
+
imkcProof: context.imkcProof,
|
|
161
|
+
receiverPubkey: routerReceiverTag,
|
|
162
|
+
fileChunkIndex,
|
|
163
|
+
chunkIndex: index++,
|
|
164
|
+
chunkTotal: total,
|
|
165
|
+
content
|
|
166
|
+
}), routerSeckey)
|
|
167
|
+
const createdAt = nowSeconds()
|
|
168
|
+
const outer = await privateChannelSigner.signEvent({
|
|
169
|
+
kind: PRIVATE_BROADCAST_KIND,
|
|
170
|
+
created_at: createdAt,
|
|
171
|
+
tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
|
|
172
|
+
content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
|
|
173
|
+
})
|
|
174
|
+
if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
|
|
175
|
+
yield outer
|
|
181
176
|
}
|
|
182
177
|
}
|
|
183
178
|
|
|
@@ -989,6 +984,7 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
|
|
|
989
984
|
})
|
|
990
985
|
|
|
991
986
|
async function consumeEvents () {
|
|
987
|
+
let result = { status: 'ended' }
|
|
992
988
|
try {
|
|
993
989
|
for await (const item of events) {
|
|
994
990
|
if (controller.signal.aborted) continue
|
|
@@ -996,19 +992,26 @@ export function subscribe ({ receiverSigner, iykcSigner, privateChannelSigner =
|
|
|
996
992
|
else if (item.type === 'event') await processOuterEvent(item.event)
|
|
997
993
|
}
|
|
998
994
|
} catch (error) {
|
|
999
|
-
if (!controller.signal.aborted
|
|
995
|
+
if (!controller.signal.aborted) {
|
|
996
|
+
const diagnostic = subscriptionError(error, error?.relay)
|
|
997
|
+
result = { status: 'failed', error: diagnostic }
|
|
998
|
+
// Consumer error callbacks must not turn `done` into an unhandled rejection.
|
|
999
|
+
try { onError?.(diagnostic) } catch {}
|
|
1000
|
+
}
|
|
1000
1001
|
} finally {
|
|
1001
1002
|
processOuterEvent.close()
|
|
1002
1003
|
}
|
|
1004
|
+
return controller.signal.aborted ? { status: 'closed' } : result
|
|
1003
1005
|
}
|
|
1004
1006
|
|
|
1005
|
-
const
|
|
1007
|
+
const done = consumeEvents()
|
|
1006
1008
|
|
|
1007
1009
|
return {
|
|
1008
1010
|
ready: events.ready || Promise.resolve(),
|
|
1011
|
+
done,
|
|
1009
1012
|
close () {
|
|
1010
1013
|
controller.abort()
|
|
1011
|
-
return
|
|
1014
|
+
return done
|
|
1012
1015
|
}
|
|
1013
1016
|
}
|
|
1014
1017
|
}
|
package/private-message/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeRelayUrl } from '../url/index.js'
|
|
1
2
|
import { isValidEvent } from '../event/index.js'
|
|
2
3
|
import { ValidationError } from '../error/index.js'
|
|
3
4
|
import { generateKeypair } from '../key/index.js'
|
|
@@ -144,9 +145,10 @@ async function ownPrivateChannelPubkey (signer) {
|
|
|
144
145
|
|
|
145
146
|
// A session owns its callbacks and relay reads. Other consumers, including
|
|
146
147
|
// the other side of the same shared channel, cannot overwrite or stop them.
|
|
147
|
-
export function createPrivateMessageSession () {
|
|
148
|
+
export function createPrivateMessageSession ({ _setTimeout = setTimeout, _clearTimeout = clearTimeout, _now = Date.now, _random = Math.random } = {}) {
|
|
148
149
|
const watchesByChannel = new Map()
|
|
149
150
|
const subsByRelay = new Map()
|
|
151
|
+
const relayRecoveries = new Map()
|
|
150
152
|
let nextWatchRevision = 1
|
|
151
153
|
let requestSequence = 0
|
|
152
154
|
const watchRequests = new Map()
|
|
@@ -301,11 +303,113 @@ export function createPrivateMessageSession () {
|
|
|
301
303
|
}
|
|
302
304
|
}
|
|
303
305
|
|
|
304
|
-
function
|
|
306
|
+
function reportSubscriptionError (channels, error) {
|
|
307
|
+
try { watchCallbacks([...channels][0]).onError?.(error) } catch {}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function cancelRelayRecovery (relay) {
|
|
311
|
+
const recovery = relayRecoveries.get(relay)
|
|
312
|
+
if (recovery?.timer != null) _clearTimeout(recovery.timer)
|
|
313
|
+
relayRecoveries.delete(relay)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function notifyInterruption (relay, recovery) {
|
|
317
|
+
const channels = desiredRelayState().get(relay) || []
|
|
318
|
+
// Each consumer must persist its gap before this shared subscription restarts.
|
|
319
|
+
// Keep acknowledgments on partial failure; new/replaced watches participate too.
|
|
320
|
+
const outcomes = await Promise.allSettled([...channels].map(async channel => {
|
|
321
|
+
const watch = watchesByChannel.get(channel)
|
|
322
|
+
if (recovery.notified.get(channel) === watch) return
|
|
323
|
+
await watch.callbacks.onSubscriptionState?.({ state: 'interrupted', relay, error: recovery.error, since: recovery.since })
|
|
324
|
+
recovery.notified.set(channel, watch)
|
|
325
|
+
}))
|
|
326
|
+
const failed = outcomes.find(result => result.status === 'rejected')
|
|
327
|
+
if (failed) throw failed.reason
|
|
328
|
+
if (relayRecoveries.get(relay) === recovery && [...(desiredRelayState().get(relay) || [])].some(channel => recovery.notified.get(channel) !== watchesByChannel.get(channel))) {
|
|
329
|
+
await notifyInterruption(relay, recovery)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function scheduleRelayRecovery (relay, recovery) {
|
|
334
|
+
if (relayRecoveries.get(relay) !== recovery || recovery.timer || !desiredRelayState().has(relay)) return
|
|
335
|
+
const delay = Math.min(30000, recovery.delay * (0.8 + _random() * 0.4))
|
|
336
|
+
recovery.delay = Math.min(30000, recovery.delay * 2)
|
|
337
|
+
recovery.timer = _setTimeout(async () => {
|
|
338
|
+
recovery.timer = null
|
|
339
|
+
try {
|
|
340
|
+
await notifyInterruption(relay, recovery)
|
|
341
|
+
if (relayRecoveries.get(relay) !== recovery || !desiredRelayState().has(relay)) return
|
|
342
|
+
await rebuildSubscriptions({ retryRelay: relay })
|
|
343
|
+
} catch (error) {
|
|
344
|
+
reportSubscriptionError(desiredRelayState().get(relay) || [], error)
|
|
345
|
+
scheduleRelayRecovery(relay, recovery)
|
|
346
|
+
}
|
|
347
|
+
}, delay)
|
|
348
|
+
recovery.timer?.unref?.()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function subscriptionEnded (relay, entry, result) {
|
|
352
|
+
if (subsByRelay.get(relay) !== entry) return
|
|
353
|
+
subsByRelay.delete(relay)
|
|
354
|
+
if (result.status === 'closed' || !desiredRelayState().has(relay)) return
|
|
355
|
+
const error = result.error || Object.assign(new Error('PRIVATE_CHANNEL_SUBSCRIPTION_ENDED'), { code: 'PRIVATE_CHANNEL_SUBSCRIPTION_ENDED', relay })
|
|
356
|
+
const previous = relayRecoveries.get(relay)
|
|
357
|
+
const recovery = {
|
|
358
|
+
error,
|
|
359
|
+
since: Math.min(entry.since, error.recoverySince ?? entry.since, previous?.since ?? Infinity),
|
|
360
|
+
delay: entry.readyAt !== undefined && _now() - entry.readyAt >= 60000 ? 1000 : (previous?.delay || entry.retryDelay || 1000),
|
|
361
|
+
notified: new Map(), timer: null
|
|
362
|
+
}
|
|
363
|
+
if (/^(auth-required:|restricted:|blocked:|invalid:|pow:)/.test(error.message || '') || error.name === 'ValidationError' ||
|
|
364
|
+
(error.code !== 'RELAY_LIVE_BUFFER_FULL' && !previous)) {
|
|
365
|
+
cancelRelayRecovery(relay)
|
|
366
|
+
notifyInterruption(relay, recovery).catch(error => reportSubscriptionError(entry.channels, error))
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
cancelRelayRecovery(relay)
|
|
370
|
+
relayRecoveries.set(relay, recovery)
|
|
371
|
+
// Start persistence immediately, without overlapping a later retry callback.
|
|
372
|
+
recovery.preparing = notifyInterruption(relay, recovery).catch(error => reportSubscriptionError(entry.channels, error))
|
|
373
|
+
recovery.preparing.finally(() => scheduleRelayRecovery(relay, recovery))
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function subscriptionReady (relay, entry, ready) {
|
|
377
|
+
if (subsByRelay.get(relay) !== entry) return
|
|
378
|
+
const recovery = relayRecoveries.get(relay)
|
|
379
|
+
if (!ready?.relays?.includes(relay)) {
|
|
380
|
+
if (recovery) {
|
|
381
|
+
const error = ready?.errors?.[0]?.reason || Object.assign(new Error('RELAY_LIVE_NOT_READY'), { code: 'RELAY_LIVE_NOT_READY', relay })
|
|
382
|
+
subscriptionEnded(relay, entry, { status: 'failed', error })
|
|
383
|
+
await entry.sub.close()
|
|
384
|
+
}
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
entry.readyAt = _now()
|
|
388
|
+
if (!recovery) return
|
|
389
|
+
try {
|
|
390
|
+
const until = Math.floor(_now() / 1000)
|
|
391
|
+
await Promise.all([...entry.channels].map(async channel => {
|
|
392
|
+
if (subsByRelay.get(relay) !== entry || !watchesByChannel.has(channel)) return
|
|
393
|
+
await watchCallbacks(channel).onSubscriptionState?.({ state: 'ready', relay, since: recovery.since, until })
|
|
394
|
+
}))
|
|
395
|
+
if (subsByRelay.get(relay) === entry && relayRecoveries.get(relay) === recovery) {
|
|
396
|
+
entry.retryDelay = recovery.delay
|
|
397
|
+
cancelRelayRecovery(relay)
|
|
398
|
+
await rebuildSubscriptions()
|
|
399
|
+
}
|
|
400
|
+
} catch (error) {
|
|
401
|
+
reportSubscriptionError(entry.channels, error)
|
|
402
|
+
subscriptionEnded(relay, entry, { status: 'failed', error })
|
|
403
|
+
await entry.sub.close()
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function rebuildSubscriptions ({ _subscribe = privateChannel.subscribe, gracefulClose = true, retryRelay } = {}) {
|
|
305
408
|
subscribe = _subscribe === privateChannel.subscribe ? subscribe : _subscribe
|
|
306
409
|
_subscribe = subscribe
|
|
307
410
|
const desired = desiredRelayState()
|
|
308
411
|
const closing = []
|
|
412
|
+
for (const relay of relayRecoveries.keys()) if (!desired.has(relay)) cancelRelayRecovery(relay)
|
|
309
413
|
|
|
310
414
|
for (const [relay, current] of subsByRelay) {
|
|
311
415
|
const nextChannels = desired.get(relay)
|
|
@@ -321,6 +425,8 @@ export function createPrivateMessageSession () {
|
|
|
321
425
|
const current = subsByRelay.get(relay)
|
|
322
426
|
if (doesSubscriptionMatch(current, channels)) continue
|
|
323
427
|
|
|
428
|
+
if (relayRecoveries.has(relay) && retryRelay !== relay) continue
|
|
429
|
+
const since = nowSeconds()
|
|
324
430
|
const channelList = [...channels]
|
|
325
431
|
const firstWatch = watchesByChannel.get(channelList[0])
|
|
326
432
|
const sub = _subscribe({
|
|
@@ -345,7 +451,7 @@ export function createPrivateMessageSession () {
|
|
|
345
451
|
ignoredGroupTtlMs: maxWatchNumber(channelList, 'ignoredGroupTtlMs'),
|
|
346
452
|
ignoredGroupMaxEntries: maxWatchNumber(channelList, 'ignoredGroupMaxEntries'),
|
|
347
453
|
limit: 0,
|
|
348
|
-
since
|
|
454
|
+
since,
|
|
349
455
|
liveOnly: true,
|
|
350
456
|
onChunk: handleChunk,
|
|
351
457
|
onEvent: (event, outer, meta) => {
|
|
@@ -358,14 +464,24 @@ export function createPrivateMessageSession () {
|
|
|
358
464
|
return dispatchSeedEvent(seed)
|
|
359
465
|
},
|
|
360
466
|
onContentKeyUsage: dispatchContentKeyUsage,
|
|
361
|
-
onError: err =>
|
|
467
|
+
onError: err => reportSubscriptionError(channels, err)
|
|
362
468
|
})
|
|
363
469
|
|
|
364
|
-
|
|
470
|
+
const entry = {
|
|
471
|
+
since,
|
|
365
472
|
channels: new Set(channels),
|
|
366
473
|
revisions: watchRevisionsForChannels(channelList),
|
|
367
474
|
sub
|
|
368
|
-
}
|
|
475
|
+
}
|
|
476
|
+
subsByRelay.set(relay, entry)
|
|
477
|
+
if (sub.done) sub.done.then(result => subscriptionEnded(relay, entry, result)).catch(error => reportSubscriptionError(channels, error))
|
|
478
|
+
if (sub.ready) {
|
|
479
|
+
sub.ready.then(ready => subscriptionReady(relay, entry, ready)).catch(error => {
|
|
480
|
+
reportSubscriptionError(channels, error)
|
|
481
|
+
subscriptionEnded(relay, entry, { status: 'failed', error })
|
|
482
|
+
Promise.resolve(sub.close()).catch(() => {})
|
|
483
|
+
})
|
|
484
|
+
}
|
|
369
485
|
if (current) {
|
|
370
486
|
const close = closeSubscription(current.sub, gracefulClose)
|
|
371
487
|
if (close) closing.push(close)
|
|
@@ -393,6 +509,7 @@ export function createPrivateMessageSession () {
|
|
|
393
509
|
onSeed,
|
|
394
510
|
onChunk,
|
|
395
511
|
onContentKeyUsage,
|
|
512
|
+
onSubscriptionState,
|
|
396
513
|
onError,
|
|
397
514
|
receivedChunkTtlMs,
|
|
398
515
|
receivedChunkMaxBytes,
|
|
@@ -403,6 +520,7 @@ export function createPrivateMessageSession () {
|
|
|
403
520
|
_subscribe = privateChannel.subscribe
|
|
404
521
|
}) {
|
|
405
522
|
if (!relays?.length) throw new ValidationError('NO_RELAYS')
|
|
523
|
+
relays = uniq(relays.map(normalizeRelayUrl))
|
|
406
524
|
const request = ++requestSequence
|
|
407
525
|
const channelList = uniq(channels?.length ? channels : [await ownPrivateChannelPubkey(privateChannelSigner)])
|
|
408
526
|
for (const channel of channelList) {
|
|
@@ -411,9 +529,8 @@ export function createPrivateMessageSession () {
|
|
|
411
529
|
const ownPubkey = receiverPubkey || await receiverSigner?.getPublicKey?.()
|
|
412
530
|
if (identity !== undefined && identity !== ownPubkey) throw new ValidationError('PRIVATE_MESSAGE_IDENTITY_MISMATCH')
|
|
413
531
|
identity = ownPubkey
|
|
414
|
-
const callbacks = { onAsk, onReply, onTell, onYell, onNym, onMessage, onSeed, onChunk, onContentKeyUsage, onError }
|
|
532
|
+
const callbacks = { onAsk, onReply, onTell, onYell, onNym, onMessage, onSeed, onChunk, onContentKeyUsage, onSubscriptionState, onError }
|
|
415
533
|
|
|
416
|
-
let changed = false
|
|
417
534
|
for (const channel of channelList) {
|
|
418
535
|
if (watchRequests.get(channel) !== request) continue
|
|
419
536
|
const next = {
|
|
@@ -457,10 +574,9 @@ export function createPrivateMessageSession () {
|
|
|
457
574
|
continue
|
|
458
575
|
}
|
|
459
576
|
watchesByChannel.set(channel, next)
|
|
460
|
-
changed = true
|
|
461
577
|
}
|
|
462
578
|
|
|
463
|
-
|
|
579
|
+
await rebuildSubscriptions({ _subscribe })
|
|
464
580
|
return () => {
|
|
465
581
|
const owned = channelList.filter(channel => watchRequests.get(channel) === request)
|
|
466
582
|
return unwatch(owned)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Retry operational interruptions, never dense-page limits or permanent refusals.
|
|
2
|
+
export function recoveryRetryDelay (errors, delay) {
|
|
3
|
+
let retry = false
|
|
4
|
+
let blocked = false
|
|
5
|
+
let retryAt = 0
|
|
6
|
+
const seen = new Set()
|
|
7
|
+
function visit (error) {
|
|
8
|
+
if (!error || seen.has(error)) return
|
|
9
|
+
seen.add(error)
|
|
10
|
+
if (error.code === 'PRIVATE_CHANNEL_HISTORY_PAGE_LIMIT' || error.name === 'ValidationError' ||
|
|
11
|
+
/^(auth-required:|restricted:|blocked:|invalid:|pow:)/.test(error.message || '')) blocked = true
|
|
12
|
+
if (['transport', 'timeout'].includes(error.category) ||
|
|
13
|
+
['QuotaExceededError', 'InvalidStateError', 'UnknownError'].includes(error.name) ||
|
|
14
|
+
/^(rate-limited:|error:)/.test(error.message || '') ||
|
|
15
|
+
['RELAY_LIVE_BUFFER_FULL', 'RELAY_LIVE_NOT_READY'].includes(error.code)) retry = true
|
|
16
|
+
if (Number.isFinite(error.retryAt)) retryAt = Math.max(retryAt, Math.min(Date.now() + 300000, error.retryAt))
|
|
17
|
+
for (const child of error.errors || []) visit(child)
|
|
18
|
+
for (const entry of error.relays || []) {
|
|
19
|
+
if (['timeout', 'cutoff', 'closed'].includes(entry.status)) retry = true
|
|
20
|
+
visit(entry.error)
|
|
21
|
+
}
|
|
22
|
+
visit(error.cause)
|
|
23
|
+
}
|
|
24
|
+
errors.forEach(visit)
|
|
25
|
+
return retry && !blocked ? Math.max(delay, retryAt - Date.now()) : null
|
|
26
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recoveryRetryDelay } from './helpers/recovery-retry.js'
|
|
1
2
|
// Expected use:
|
|
2
3
|
// const messenger = await createPrivateMessenger({
|
|
3
4
|
// userSigner,
|
|
@@ -293,6 +294,8 @@ export class PrivateMessenger {
|
|
|
293
294
|
this.capacityCheck = null
|
|
294
295
|
this.capacityRequiredBytes = 0
|
|
295
296
|
this.reloadGapTimers = new Map()
|
|
297
|
+
this.liveInterruptions = new Map()
|
|
298
|
+
this.liveRecoveryTimers = new Map()
|
|
296
299
|
this.watchRevisionByChannel = new Map()
|
|
297
300
|
this.presenceTimers = new Map()
|
|
298
301
|
this.stopRelayListWatcher = null
|
|
@@ -1103,6 +1106,7 @@ export class PrivateMessenger {
|
|
|
1103
1106
|
onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
|
|
1104
1107
|
receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
|
|
1105
1108
|
receivedChunkIndexedDB: this._indexedDB,
|
|
1109
|
+
onSubscriptionState: state => this.handleSubscriptionState(pubkey, state),
|
|
1106
1110
|
onError: err => this.onError?.(err)
|
|
1107
1111
|
})
|
|
1108
1112
|
if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey) || revision !== (this.watchRevisionByChannel.get(pubkey) || 0)) {
|
|
@@ -1136,6 +1140,76 @@ export class PrivateMessenger {
|
|
|
1136
1140
|
return this
|
|
1137
1141
|
}
|
|
1138
1142
|
|
|
1143
|
+
async handleSubscriptionState (pubkey, { state, relay, since, until }) {
|
|
1144
|
+
if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey)) return
|
|
1145
|
+
if (!this.offlineRecoverySecondsFor(pubkey)) return
|
|
1146
|
+
let interrupted = this.liveInterruptions.get(pubkey)
|
|
1147
|
+
if (state === 'interrupted') {
|
|
1148
|
+
if (!interrupted) { interrupted = new Map(); this.liveInterruptions.set(pubkey, interrupted) }
|
|
1149
|
+
const start = Math.min(interrupted.get(relay) ?? Infinity, since)
|
|
1150
|
+
interrupted.set(relay, start)
|
|
1151
|
+
this.cancelReloadGap(pubkey)
|
|
1152
|
+
for (const controller of this.recoveryControllers) {
|
|
1153
|
+
if (controller.channelPubkey === pubkey) { controller.liveInterrupted = true; controller.abort() }
|
|
1154
|
+
}
|
|
1155
|
+
this.recordInterruption([pubkey], start)
|
|
1156
|
+
this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), nowSeconds())
|
|
1157
|
+
// This rejection prevents the session from reopening the shared relay.
|
|
1158
|
+
await this.flushStateWrites()
|
|
1159
|
+
} else if (state === 'ready' && interrupted?.has(relay)) {
|
|
1160
|
+
const start = interrupted.get(relay)
|
|
1161
|
+
this.addOfflineRange(pubkey, Math.max(0, start - this.offlineSkewSeconds), until)
|
|
1162
|
+
if (interrupted.size === 1) this.closeOpenOfflineRanges([pubkey])
|
|
1163
|
+
await this.flushStateWrites()
|
|
1164
|
+
if (this.closePromise || this.pauseReasons.size || !this.desiredChannels.has(pubkey) || this.liveInterruptions.get(pubkey) !== interrupted) return
|
|
1165
|
+
interrupted.delete(relay)
|
|
1166
|
+
if (!interrupted.size) this.liveInterruptions.delete(pubkey)
|
|
1167
|
+
this.scheduleLiveRecovery(pubkey)
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
cancelLiveRecovery (pubkey) {
|
|
1172
|
+
const work = this.liveRecoveryTimers.get(pubkey)
|
|
1173
|
+
if (work?.timer != null) this._clearTimeout(work.timer)
|
|
1174
|
+
this.liveRecoveryTimers.delete(pubkey)
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
scheduleLiveRecovery (pubkey) {
|
|
1178
|
+
const current = this.liveRecoveryTimers.get(pubkey)
|
|
1179
|
+
if (current) { current.again = true; return }
|
|
1180
|
+
const work = { delay: 1000, timer: null, again: false }
|
|
1181
|
+
this.liveRecoveryTimers.set(pubkey, work)
|
|
1182
|
+
const isCurrent = () => this.liveRecoveryTimers.get(pubkey) === work && !this.closePromise &&
|
|
1183
|
+
!this.pauseReasons.size && this.desiredChannels.has(pubkey)
|
|
1184
|
+
const schedule = delay => {
|
|
1185
|
+
work.timer = this._setTimeout(async () => {
|
|
1186
|
+
work.timer = null
|
|
1187
|
+
if (!isCurrent()) return
|
|
1188
|
+
work.again = false
|
|
1189
|
+
let errors
|
|
1190
|
+
try {
|
|
1191
|
+
// A ready notification may overtake cancellation of the previous scan.
|
|
1192
|
+
// Let that scan retain its range, then start a fresh recovery attempt.
|
|
1193
|
+
await this.recoveries.get(pubkey)
|
|
1194
|
+
if (!isCurrent()) return
|
|
1195
|
+
errors = await this.recoverOfflineRanges([pubkey])
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
this.onError?.(error)
|
|
1198
|
+
errors = [error]
|
|
1199
|
+
}
|
|
1200
|
+
if (!isCurrent()) return
|
|
1201
|
+
const pending = this.readState().channels[pubkey]?.offlineRanges?.length
|
|
1202
|
+
const retry = pending ? recoveryRetryDelay(errors, Math.min(30000, work.delay * (0.8 + Math.random() * 0.4))) : null
|
|
1203
|
+
if (work.again || retry !== null) {
|
|
1204
|
+
schedule(work.again ? 0 : retry)
|
|
1205
|
+
work.delay = Math.min(30000, work.delay * 2)
|
|
1206
|
+
} else this.liveRecoveryTimers.delete(pubkey)
|
|
1207
|
+
}, delay)
|
|
1208
|
+
work.timer?.unref?.()
|
|
1209
|
+
}
|
|
1210
|
+
schedule(0)
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1139
1213
|
recordInterruption (channels, at = nowSeconds()) {
|
|
1140
1214
|
const state = this.readState()
|
|
1141
1215
|
for (const pubkey of channels) {
|
|
@@ -1152,6 +1226,8 @@ export class PrivateMessenger {
|
|
|
1152
1226
|
const closing = []
|
|
1153
1227
|
for (const pubkey of channels) {
|
|
1154
1228
|
this.cancelReloadGap(pubkey)
|
|
1229
|
+
this.cancelLiveRecovery(pubkey)
|
|
1230
|
+
this.liveInterruptions.delete(pubkey)
|
|
1155
1231
|
this.watchRevisionByChannel.set(pubkey, (this.watchRevisionByChannel.get(pubkey) || 0) + 1)
|
|
1156
1232
|
const close = this.stopByChannel.get(pubkey)?.()
|
|
1157
1233
|
if (close?.then) closing.push(close)
|
|
@@ -2063,6 +2139,7 @@ export class PrivateMessenger {
|
|
|
2063
2139
|
}
|
|
2064
2140
|
|
|
2065
2141
|
async recoverOfflineRanges (channels = [...this.stopByChannel.keys()]) {
|
|
2142
|
+
const failures = []
|
|
2066
2143
|
for (const pubkey of uniq(channels)) {
|
|
2067
2144
|
if (this.pauseReasons.size || this.closePromise || !this.desiredChannels.has(pubkey)) continue
|
|
2068
2145
|
let work = this.recoveries.get(pubkey)
|
|
@@ -2070,11 +2147,13 @@ export class PrivateMessenger {
|
|
|
2070
2147
|
work = this.recoverChannelRanges([pubkey])
|
|
2071
2148
|
this.recoveries.set(pubkey, work)
|
|
2072
2149
|
}
|
|
2073
|
-
try { await work } finally { if (this.recoveries.get(pubkey) === work) this.recoveries.delete(pubkey) }
|
|
2150
|
+
try { failures.push(...await work) } finally { if (this.recoveries.get(pubkey) === work) this.recoveries.delete(pubkey) }
|
|
2074
2151
|
}
|
|
2152
|
+
return failures
|
|
2075
2153
|
}
|
|
2076
2154
|
|
|
2077
2155
|
async recoverChannelRanges (channels) {
|
|
2156
|
+
const failures = []
|
|
2078
2157
|
const state = this.readState()
|
|
2079
2158
|
const now = nowSeconds()
|
|
2080
2159
|
|
|
@@ -2125,12 +2204,14 @@ export class PrivateMessenger {
|
|
|
2125
2204
|
})
|
|
2126
2205
|
controller.signal.throwIfAborted()
|
|
2127
2206
|
const attempt = await this.#askSeedersForRelayLeftEdgeAttempt(pubkey, range, history.oldestCreatedAt)
|
|
2128
|
-
const lifecycleChanged = this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
|
|
2207
|
+
const lifecycleChanged = controller.signal.aborted || this.closePromise || !this.channels.has(pubkey) || !this.stopByChannel.has(pubkey) ||
|
|
2129
2208
|
(this.watchRevisionByChannel.get(pubkey) || 0) !== watchRevision
|
|
2209
|
+
failures.push(...attempt.failures.map(failure => failure.error))
|
|
2130
2210
|
if (lifecycleChanged || attempt.failures.length) remaining.push(range)
|
|
2131
2211
|
else recoveredThrough = Math.max(recoveredThrough, range.end)
|
|
2132
2212
|
} catch (err) {
|
|
2133
|
-
this.onError?.(err)
|
|
2213
|
+
if (!(controller.liveInterrupted && err === controller.signal.reason)) this.onError?.(err)
|
|
2214
|
+
failures.push(err)
|
|
2134
2215
|
remaining.push(range)
|
|
2135
2216
|
} finally { this.recoveryControllers.delete(controller) }
|
|
2136
2217
|
}
|
|
@@ -2145,6 +2226,7 @@ export class PrivateMessenger {
|
|
|
2145
2226
|
this.writeState(fresh)
|
|
2146
2227
|
await this.flushStateWrites()
|
|
2147
2228
|
}
|
|
2229
|
+
return failures
|
|
2148
2230
|
}
|
|
2149
2231
|
|
|
2150
2232
|
async clearChannel (pubkey) {
|
|
@@ -9,6 +9,7 @@ export const MISSING_MESSAGES_REPLY_CODE = 'missingMessages_reply_8mj8'
|
|
|
9
9
|
export const ROUTER_SEED_RECORD_TYPE = 'routerEnvelopeRow_v1'
|
|
10
10
|
export const NYM_CARRIER_SEED_RECORD_TYPE = 'nymCarrier_v1'
|
|
11
11
|
|
|
12
|
+
export const DEFAULT_REPLY_BYTES_PER_CHUNK = 128 * 1024
|
|
12
13
|
const DEFAULT_EVENTS_PER_CHUNK = 100
|
|
13
14
|
const encoder = new TextEncoder()
|
|
14
15
|
const decoder = new TextDecoder()
|
|
@@ -224,6 +225,7 @@ export function createEventReplyPacker ({
|
|
|
224
225
|
code,
|
|
225
226
|
payload = {},
|
|
226
227
|
eventsPerChunk = DEFAULT_EVENTS_PER_CHUNK,
|
|
228
|
+
bytesPerChunk = DEFAULT_REPLY_BYTES_PER_CHUNK,
|
|
227
229
|
recordsFromInput = eventRecordFromInput,
|
|
228
230
|
sendEmptyReply = false
|
|
229
231
|
}) {
|
|
@@ -231,18 +233,17 @@ export function createEventReplyPacker ({
|
|
|
231
233
|
if (!question?.id) throw new ValidationError('QUESTION_REQUIRED')
|
|
232
234
|
if (!receiverPubkey) throw new ValidationError('RECEIVER_PUBKEY_REQUIRED')
|
|
233
235
|
if (!Number.isSafeInteger(eventsPerChunk) || eventsPerChunk < 1) throw new ValidationError('INVALID_EVENTS_PER_CHUNK')
|
|
236
|
+
if (!Number.isSafeInteger(bytesPerChunk) || bytesPerChunk < 1) throw new ValidationError('INVALID_BYTES_PER_CHUNK')
|
|
234
237
|
|
|
235
238
|
let chunk = ''
|
|
236
239
|
let chunkEvents = 0
|
|
240
|
+
let chunkBytes = 0
|
|
237
241
|
let index = 0
|
|
238
242
|
let finalized = false
|
|
239
243
|
let published = false
|
|
240
244
|
|
|
241
245
|
async function publish (isLast) {
|
|
242
246
|
const jsonl = chunk
|
|
243
|
-
chunk = ''
|
|
244
|
-
chunkEvents = 0
|
|
245
|
-
published = true
|
|
246
247
|
await messenger.reply({
|
|
247
248
|
channelPubkey,
|
|
248
249
|
question,
|
|
@@ -250,17 +251,27 @@ export function createEventReplyPacker ({
|
|
|
250
251
|
code,
|
|
251
252
|
payload: {
|
|
252
253
|
...payload,
|
|
253
|
-
index
|
|
254
|
+
index,
|
|
254
255
|
isLast,
|
|
255
256
|
jsonl
|
|
256
257
|
}
|
|
257
258
|
})
|
|
259
|
+
index++
|
|
260
|
+
chunk = ''
|
|
261
|
+
chunkEvents = 0
|
|
262
|
+
chunkBytes = 0
|
|
263
|
+
published = true
|
|
258
264
|
}
|
|
259
265
|
|
|
260
266
|
async function appendRecord (record, { flush = true } = {}) {
|
|
261
|
-
|
|
267
|
+
const line = `${JSON.stringify(record)}\n`
|
|
268
|
+
const bytes = encoder.encode(line).length
|
|
269
|
+
if (chunk && (chunkBytes + bytes > bytesPerChunk || chunkEvents >= eventsPerChunk)) await publish(false)
|
|
270
|
+
chunk += line
|
|
271
|
+
chunkBytes += bytes
|
|
262
272
|
chunkEvents++
|
|
263
|
-
|
|
273
|
+
// A record is indivisible: an oversized one travels alone, never truncated.
|
|
274
|
+
if (flush && (chunkEvents >= eventsPerChunk || chunkBytes >= bytesPerChunk)) await publish(false)
|
|
264
275
|
}
|
|
265
276
|
|
|
266
277
|
async function appendRecords (records, { final = false } = {}) {
|
|
@@ -278,9 +289,8 @@ export function createEventReplyPacker ({
|
|
|
278
289
|
async function finalize (input) {
|
|
279
290
|
if (finalized) return
|
|
280
291
|
if (input != null) await appendRecords(await recordsFromInput(input), { final: true })
|
|
292
|
+
if (chunk || sendEmptyReply || published) await publish(true)
|
|
281
293
|
finalized = true
|
|
282
|
-
if (!chunk && !sendEmptyReply && !published) return
|
|
283
|
-
await publish(true)
|
|
284
294
|
}
|
|
285
295
|
|
|
286
296
|
return {
|
|
@@ -297,12 +307,14 @@ export function createMissingMessageReplyPacker ({
|
|
|
297
307
|
since,
|
|
298
308
|
until,
|
|
299
309
|
eventsPerChunk = DEFAULT_EVENTS_PER_CHUNK,
|
|
310
|
+
bytesPerChunk = DEFAULT_REPLY_BYTES_PER_CHUNK,
|
|
300
311
|
sendEmptyReply = false
|
|
301
312
|
}) {
|
|
302
313
|
if (!messenger?.reply) throw new ValidationError('MESSENGER_REQUIRED')
|
|
303
314
|
if (!question?.id) throw new ValidationError('QUESTION_REQUIRED')
|
|
304
315
|
if (!receiverPubkey) throw new ValidationError('RECEIVER_PUBKEY_REQUIRED')
|
|
305
316
|
if (!Number.isSafeInteger(eventsPerChunk) || eventsPerChunk < 1) throw new ValidationError('INVALID_EVENTS_PER_CHUNK')
|
|
317
|
+
if (!Number.isSafeInteger(bytesPerChunk) || bytesPerChunk < 1) throw new ValidationError('INVALID_BYTES_PER_CHUNK')
|
|
306
318
|
|
|
307
319
|
const range = backfillRequestRange(question, since, until)
|
|
308
320
|
return createEventReplyPacker({
|
|
@@ -313,6 +325,7 @@ export function createMissingMessageReplyPacker ({
|
|
|
313
325
|
code: MISSING_MESSAGES_REPLY_CODE,
|
|
314
326
|
payload: { since: range.since, until: range.until },
|
|
315
327
|
eventsPerChunk,
|
|
328
|
+
bytesPerChunk,
|
|
316
329
|
sendEmptyReply,
|
|
317
330
|
recordsFromInput: seed => compactRecordsFromSeed(seed, { receiverPubkey, since: range.since, until: range.until })
|
|
318
331
|
})
|
package/relay/helpers/error.js
CHANGED
|
@@ -28,3 +28,15 @@ export function relayCloseError (event, category, cause) {
|
|
|
28
28
|
if (event?.wasClean !== undefined) error.wasClean = event.wasClean
|
|
29
29
|
return categorizeRelayError(error, category)
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
// retry_after is an optional relay extension, in seconds. Bound remote advice
|
|
33
|
+
// to five minutes; invalid metadata must not stall unrelated operations.
|
|
34
|
+
export function relayRejectionError (reason, extra, fallback) {
|
|
35
|
+
const error = categorizeRelayError(reason, 'relay', fallback)
|
|
36
|
+
const seconds = extra?.retry_after
|
|
37
|
+
if (error.message.startsWith('rate-limited:') && typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0) {
|
|
38
|
+
error.retryAfterMs = Math.min(seconds, 300) * 1000
|
|
39
|
+
error.retryAt = Date.now() + error.retryAfterMs
|
|
40
|
+
}
|
|
41
|
+
return error
|
|
42
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Bounded diagnostics only: never attach queued payloads or subscription filters.
|
|
2
|
+
export function liveBufferError ({ relay, stage, queue, queuedBytes, incoming, incomingBytes, maxEvents, maxBytes, since }) {
|
|
3
|
+
const now = Date.now()
|
|
4
|
+
const boundaries = [since, incoming?.event?.created_at, ...queue.map(entry => entry.item?.event?.created_at)]
|
|
5
|
+
.filter(value => Number.isFinite(value) && value >= 0)
|
|
6
|
+
return Object.assign(new Error('RELAY_LIVE_BUFFER_FULL'), {
|
|
7
|
+
code: 'RELAY_LIVE_BUFFER_FULL', relay, phase: 'live-buffer',
|
|
8
|
+
recoverySince: Math.min(Math.floor(now / 1000), ...boundaries),
|
|
9
|
+
buffer: {
|
|
10
|
+
stage, queuedEvents: queue.length, queuedBytes, incomingBytes,
|
|
11
|
+
limits: { events: maxEvents, bytes: maxBytes },
|
|
12
|
+
oldestQueuedMs: queue.length ? Math.max(0, now - queue[0].receivedAt) : 0
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isValidEvent } from '../../event/index.js'
|
|
2
2
|
import { ValidationError } from '../../error/index.js'
|
|
3
3
|
import { maybeUnref } from '../helpers/timer.js'
|
|
4
|
-
import { categorizeRelayError, relayCloseError, relayTimeoutError } from '../helpers/error.js'
|
|
4
|
+
import { categorizeRelayError, relayCloseError, relayTimeoutError, relayRejectionError } from '../helpers/error.js'
|
|
5
5
|
|
|
6
6
|
const DEFAULT_CONNECT_TIMEOUT = 3000
|
|
7
7
|
const DEFAULT_OPERATION_TIMEOUT = 30000
|
|
@@ -50,6 +50,7 @@ export class RelayConnection {
|
|
|
50
50
|
#publishes = new Map()
|
|
51
51
|
#authentications = new Map()
|
|
52
52
|
#counts = new Map()
|
|
53
|
+
#retryAt = 0
|
|
53
54
|
|
|
54
55
|
constructor (url, { WebSocket: WebSocketImpl = globalThis.WebSocket } = {}) {
|
|
55
56
|
this.url = url
|
|
@@ -121,47 +122,87 @@ export class RelayConnection {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
// New work respects relay cooldowns; cleanup uses send() directly. These
|
|
126
|
+
// waits stay within the caller's existing deadline and are cancellable.
|
|
127
|
+
#dispatchWork (send, fail) {
|
|
128
|
+
let timer = null
|
|
129
|
+
let cancelled = false
|
|
130
|
+
const dispatch = () => {
|
|
131
|
+
if (cancelled) return
|
|
132
|
+
const remaining = this.#retryAt - Date.now()
|
|
133
|
+
if (remaining > 0) {
|
|
134
|
+
timer = maybeUnref(setTimeout(dispatch, remaining))
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
try { send() } catch (error) { fail(error) }
|
|
138
|
+
}
|
|
139
|
+
dispatch()
|
|
140
|
+
return () => { cancelled = true; clearTimeout(timer) }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#rejection (reason, extra, fallback) {
|
|
144
|
+
const error = relayRejectionError(reason, extra, fallback)
|
|
145
|
+
this.#retryAt = Math.max(this.#retryAt, error.retryAt || 0)
|
|
146
|
+
return error
|
|
147
|
+
}
|
|
148
|
+
|
|
124
149
|
subscribe (filters, handlers = {}) {
|
|
125
150
|
if (!Array.isArray(filters) || !filters.length) throw new ValidationError('SUBSCRIPTION_FILTERS_REQUIRED')
|
|
126
151
|
const id = `p2r2p-sub:${++this.#serial}`
|
|
127
152
|
let closed = false
|
|
153
|
+
let sent = false
|
|
128
154
|
const close = () => {
|
|
129
155
|
if (closed) return
|
|
130
156
|
closed = true
|
|
131
157
|
const subscription = this.#subscriptions.get(id)
|
|
132
158
|
if (!subscription) return
|
|
133
159
|
this.#subscriptions.delete(id)
|
|
134
|
-
|
|
160
|
+
subscription.cancelSend?.()
|
|
161
|
+
if (sent) { try { this.send(JSON.stringify(['CLOSE', id])) } catch {} }
|
|
135
162
|
handlers.onclose?.()
|
|
136
163
|
}
|
|
137
|
-
|
|
138
|
-
|
|
164
|
+
const subscription = { filters, handlers, close, cancelSend: null }
|
|
165
|
+
this.#subscriptions.set(id, subscription)
|
|
166
|
+
subscription.cancelSend = this.#dispatchWork(() => {
|
|
167
|
+
this.send(JSON.stringify(['REQ', id, ...filters]))
|
|
168
|
+
sent = true
|
|
169
|
+
}, error => {
|
|
139
170
|
this.#subscriptions.delete(id)
|
|
140
|
-
|
|
141
|
-
}
|
|
171
|
+
handlers.onclose?.(error)
|
|
172
|
+
})
|
|
142
173
|
return { id, close }
|
|
143
174
|
}
|
|
144
175
|
|
|
145
|
-
publish (event) {
|
|
176
|
+
publish (event, { signal } = {}) {
|
|
146
177
|
if (!isValidEvent(event)) return Promise.reject(new Error('INVALID_EVENT'))
|
|
147
|
-
return this.#sendEventOperation('EVENT', event, this.#publishes, 'PUBLISH_TIMEOUT')
|
|
178
|
+
return this.#sendEventOperation('EVENT', event, this.#publishes, 'PUBLISH_TIMEOUT', signal)
|
|
148
179
|
}
|
|
149
180
|
|
|
150
|
-
async authenticate (getAuthEvent) {
|
|
181
|
+
async authenticate (getAuthEvent, { signal } = {}) {
|
|
151
182
|
if (!this.#challenge) throw new Error('AUTH_CHALLENGE_MISSING')
|
|
152
183
|
const event = await getAuthEvent({ relay: this.url, challenge: this.#challenge })
|
|
153
184
|
if (!isValidEvent(event)) throw new ValidationError('INVALID_AUTH_EVENT')
|
|
154
|
-
return await this.#sendEventOperation('AUTH', event, this.#authentications, 'AUTH_TIMEOUT')
|
|
185
|
+
return await this.#sendEventOperation('AUTH', event, this.#authentications, 'AUTH_TIMEOUT', signal)
|
|
155
186
|
}
|
|
156
187
|
|
|
157
|
-
#sendEventOperation (type, event, map, timeoutCode) {
|
|
188
|
+
#sendEventOperation (type, event, map, timeoutCode, signal) {
|
|
189
|
+
if (signal?.aborted) return Promise.reject(signal.reason || relayTimeoutError(timeoutCode))
|
|
158
190
|
if (map.has(event.id)) return map.get(event.id).promise
|
|
159
191
|
const deferred = Promise.withResolvers()
|
|
160
192
|
const timer = maybeUnref(setTimeout(() => this.#settleEvent(map, event.id, relayTimeoutError(timeoutCode, this.#lastTransportError)), this.publishTimeout))
|
|
161
|
-
|
|
162
|
-
|
|
193
|
+
const onAbort = () => this.#settleEvent(map, event.id, signal.reason || relayTimeoutError(timeoutCode))
|
|
194
|
+
const stopAbort = () => signal?.removeEventListener('abort', onAbort)
|
|
195
|
+
const pending = { ...deferred, timer, promise: deferred.promise, cancelSend: null, stopAbort }
|
|
196
|
+
map.set(event.id, pending)
|
|
197
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
198
|
+
pending.cancelSend = this.#dispatchWork(() => {
|
|
199
|
+
// Once transmitted, keep the existing ACK/report semantics. Before that,
|
|
200
|
+
// an operation-wide deadline must cancel the deferred publication.
|
|
201
|
+
stopAbort()
|
|
202
|
+
this.send(JSON.stringify([type, event]))
|
|
203
|
+
}, error => {
|
|
163
204
|
this.#settleEvent(map, event.id, error)
|
|
164
|
-
}
|
|
205
|
+
})
|
|
165
206
|
return deferred.promise
|
|
166
207
|
}
|
|
167
208
|
|
|
@@ -170,11 +211,12 @@ export class RelayConnection {
|
|
|
170
211
|
const id = `p2r2p-count:${++this.#serial}`
|
|
171
212
|
const deferred = Promise.withResolvers()
|
|
172
213
|
const onAbort = () => this.#settleCount(id, null, new Error('COUNT_ABORTED'))
|
|
173
|
-
|
|
214
|
+
const pending = { ...deferred, signal, onAbort, cancelSend: null }
|
|
215
|
+
this.#counts.set(id, pending)
|
|
174
216
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
175
|
-
|
|
217
|
+
pending.cancelSend = this.#dispatchWork(() => this.send(JSON.stringify(['COUNT', id, ...filters])), error => {
|
|
176
218
|
this.#settleCount(id, null, error)
|
|
177
|
-
}
|
|
219
|
+
})
|
|
178
220
|
return deferred.promise
|
|
179
221
|
}
|
|
180
222
|
|
|
@@ -182,6 +224,8 @@ export class RelayConnection {
|
|
|
182
224
|
const pending = map.get(id)
|
|
183
225
|
if (!pending) return
|
|
184
226
|
map.delete(id)
|
|
227
|
+
pending.cancelSend?.()
|
|
228
|
+
pending.stopAbort?.()
|
|
185
229
|
clearTimeout(pending.timer)
|
|
186
230
|
if (reason) pending.reject(errorFrom(reason, 'OPERATION_REJECTED'))
|
|
187
231
|
else pending.resolve(value)
|
|
@@ -191,6 +235,7 @@ export class RelayConnection {
|
|
|
191
235
|
const pending = this.#counts.get(id)
|
|
192
236
|
if (!pending) return
|
|
193
237
|
this.#counts.delete(id)
|
|
238
|
+
pending.cancelSend?.()
|
|
194
239
|
pending.signal?.removeEventListener('abort', pending.onAbort)
|
|
195
240
|
if (reason) pending.reject(errorFrom(reason, 'COUNT_REJECTED'))
|
|
196
241
|
else pending.resolve(payload)
|
|
@@ -219,14 +264,16 @@ export class RelayConnection {
|
|
|
219
264
|
if (data[0] === 'CLOSED') {
|
|
220
265
|
const id = data[1]
|
|
221
266
|
const subscription = this.#subscriptions.get(id)
|
|
267
|
+
const reason = this.#rejection(data[2], data[3], subscription ? 'SUBSCRIPTION_CLOSED' : 'COUNT_CLOSED')
|
|
222
268
|
if (subscription) {
|
|
223
269
|
this.#subscriptions.delete(id)
|
|
224
|
-
subscription.
|
|
225
|
-
|
|
270
|
+
subscription.cancelSend?.()
|
|
271
|
+
subscription.handlers.onclose?.(reason)
|
|
272
|
+
} else this.#settleCount(id, null, reason)
|
|
226
273
|
return
|
|
227
274
|
}
|
|
228
275
|
if (data[0] === 'OK') {
|
|
229
|
-
const reason = data[2] === true ? null :
|
|
276
|
+
const reason = data[2] === true ? null : this.#rejection(data[3], data[4], 'EVENT_REJECTED')
|
|
230
277
|
this.#settleEvent(this.#publishes, data[1], reason, data[3])
|
|
231
278
|
this.#settleEvent(this.#authentications, data[1], reason, data[3])
|
|
232
279
|
return
|
|
@@ -248,6 +295,7 @@ export class RelayConnection {
|
|
|
248
295
|
const reason = relayCloseError(event, 'transport', this.#lastTransportError)
|
|
249
296
|
for (const [id, subscription] of this.#subscriptions) {
|
|
250
297
|
this.#subscriptions.delete(id)
|
|
298
|
+
subscription.cancelSend?.()
|
|
251
299
|
subscription.handlers.onclose?.(reason)
|
|
252
300
|
}
|
|
253
301
|
for (const id of [...this.#publishes.keys()]) this.#settleEvent(this.#publishes, id, reason)
|
|
@@ -263,6 +311,7 @@ export class RelayConnection {
|
|
|
263
311
|
const reason = relayCloseError(null, 'transport', this.#lastTransportError)
|
|
264
312
|
for (const [id, subscription] of this.#subscriptions) {
|
|
265
313
|
this.#subscriptions.delete(id)
|
|
314
|
+
subscription.cancelSend?.()
|
|
266
315
|
subscription.handlers.onclose?.()
|
|
267
316
|
}
|
|
268
317
|
for (const id of [...this.#publishes.keys()]) this.#settleEvent(this.#publishes, id, reason)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { liveBufferError } from '../helpers/live-buffer-error.js'
|
|
1
2
|
import { ValidationError } from '../../error/index.js'
|
|
2
3
|
import { decodeHll, encodeHll, estimateHllCount, mergeHll } from '../helpers/hll.js'
|
|
3
4
|
import { createPublishSettlements, firstFulfillment, publishSummary } from '../helpers/publish.js'
|
|
@@ -233,21 +234,21 @@ export class RelayPool {
|
|
|
233
234
|
|
|
234
235
|
// NIP-42 retries happen inside one relay attempt, so sendEvent still reports
|
|
235
236
|
// exactly one terminal outcome for each relay URL.
|
|
236
|
-
async #publishEvent (relay, event, getAuthEvent) {
|
|
237
|
+
async #publishEvent (relay, event, getAuthEvent, signal) {
|
|
237
238
|
try {
|
|
238
|
-
await relay.publish(event)
|
|
239
|
+
await relay.publish(event, { signal })
|
|
239
240
|
return 'published'
|
|
240
241
|
} catch (error) {
|
|
241
242
|
const reason = error instanceof Error ? error : new Error(String(error))
|
|
242
243
|
if (!getAuthEvent || !requiresNip42Auth(reason)) throw reason
|
|
243
244
|
|
|
244
245
|
try {
|
|
245
|
-
await relay.authenticate(getAuthEvent)
|
|
246
|
+
await relay.authenticate(getAuthEvent, { signal })
|
|
246
247
|
} catch (error) {
|
|
247
248
|
const authReason = error instanceof Error ? error : new Error(String(error))
|
|
248
249
|
throw new Nip42AuthenticationError(authReason)
|
|
249
250
|
}
|
|
250
|
-
await relay.publish(event)
|
|
251
|
+
await relay.publish(event, { signal })
|
|
251
252
|
return 'published'
|
|
252
253
|
}
|
|
253
254
|
}
|
|
@@ -651,18 +652,23 @@ export class RelayPool {
|
|
|
651
652
|
const seenIds = new Set()
|
|
652
653
|
|
|
653
654
|
let untilTimer = null
|
|
654
|
-
const overflow =
|
|
655
|
-
|
|
655
|
+
const overflow = (item, bytes, stage = 'delivery', entries = queue, volume = queuedBytes) => {
|
|
656
|
+
if (isDone) return
|
|
657
|
+
failure = liveBufferError({
|
|
658
|
+
relay: item.relay, stage, queue: entries, queuedBytes: volume, incoming: item, incomingBytes: bytes,
|
|
659
|
+
maxEvents: maxBufferedLiveEvents, maxBytes: maxBufferedLiveBytes,
|
|
660
|
+
since: Math.min(sinceFloor, ...pendingGaps.values(), ...queue.map(entry => entry.item?.event?.created_at).filter(Number.isFinite))
|
|
661
|
+
})
|
|
656
662
|
teardown()
|
|
657
663
|
}
|
|
658
664
|
const enqueue = item => {
|
|
659
665
|
const bytes = encoder.encode(JSON.stringify(item)).byteLength
|
|
660
666
|
if (queue.length >= maxBufferedLiveEvents || queuedBytes + bytes > maxBufferedLiveBytes) {
|
|
661
|
-
overflow(item
|
|
667
|
+
overflow(item, bytes)
|
|
662
668
|
return
|
|
663
669
|
}
|
|
664
670
|
queuedBytes += bytes
|
|
665
|
-
queue.push({ item, bytes })
|
|
671
|
+
queue.push({ item, bytes, receivedAt: Date.now() })
|
|
666
672
|
p.resolve()
|
|
667
673
|
p = Promise.withResolvers()
|
|
668
674
|
}
|
|
@@ -863,11 +869,11 @@ export class RelayPool {
|
|
|
863
869
|
if (liveBuffer) {
|
|
864
870
|
const bytes = encoder.encode(JSON.stringify(event)).byteLength
|
|
865
871
|
if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
|
|
866
|
-
overflow(url)
|
|
872
|
+
overflow({ event, relay: url }, bytes, 'reconnect', liveBuffer, bufferedBytes)
|
|
867
873
|
return
|
|
868
874
|
}
|
|
869
875
|
bufferedBytes += bytes
|
|
870
|
-
liveBuffer.push(event)
|
|
876
|
+
liveBuffer.push({ item: { event, relay: url }, receivedAt: Date.now() })
|
|
871
877
|
} else pushEvent(event, url)
|
|
872
878
|
},
|
|
873
879
|
onclose: error => {
|
|
@@ -906,7 +912,7 @@ export class RelayPool {
|
|
|
906
912
|
}).finally(() => {
|
|
907
913
|
const buf = liveBuffer
|
|
908
914
|
liveBuffer = null
|
|
909
|
-
for (const
|
|
915
|
+
for (const entry of buf) pushEvent(entry.item.event, url, true)
|
|
910
916
|
gapTasks.delete(task)
|
|
911
917
|
scheduleProgress(attempt)
|
|
912
918
|
p.resolve()
|
|
@@ -1052,9 +1058,9 @@ export class RelayPool {
|
|
|
1052
1058
|
else {
|
|
1053
1059
|
const bytes = encoder.encode(JSON.stringify(item)).byteLength
|
|
1054
1060
|
if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
|
|
1055
|
-
throw
|
|
1061
|
+
throw liveBufferError({ relay: item.relay, stage: 'history-wait', queue: liveBuffer, queuedBytes: bufferedBytes, incoming: item, incomingBytes: bytes, maxEvents: maxBufferedLiveEvents, maxBytes: maxBufferedLiveBytes, since: filter.since ?? 0 })
|
|
1056
1062
|
}
|
|
1057
|
-
liveBuffer.push({ item, bytes })
|
|
1063
|
+
liveBuffer.push({ item, bytes, receivedAt: Date.now() })
|
|
1058
1064
|
bufferedBytes += bytes
|
|
1059
1065
|
}
|
|
1060
1066
|
liveWake.resolve()
|
|
@@ -1144,12 +1150,14 @@ export class RelayPool {
|
|
|
1144
1150
|
const eventToSend = event.meta ? { ...event } : event
|
|
1145
1151
|
if (eventToSend.meta) delete eventToSend.meta
|
|
1146
1152
|
|
|
1153
|
+
const sendControllers = urls.map(() => new AbortController())
|
|
1147
1154
|
const sendDeferreds = urls.map(() => Promise.withResolvers())
|
|
1148
1155
|
const sendPromises = sendDeferreds.map(({ promise }) => promise)
|
|
1149
1156
|
|
|
1150
1157
|
// Starts before connection work so every relay shares one real deadline.
|
|
1151
1158
|
const settlement = createPublishSettlements(sendPromises, timeout, {
|
|
1152
1159
|
onSettled: (settlement, index) => {
|
|
1160
|
+
sendControllers[index].abort(settlement.reason)
|
|
1153
1161
|
if (settlement.reason?.category === 'timeout' && !settlement.reason.cause) {
|
|
1154
1162
|
const relay = this.#relays.get(normalizeRelayUrl(urls[index]))
|
|
1155
1163
|
if (relay?.lastTransportError) settlement.reason.cause = relay.lastTransportError
|
|
@@ -1171,7 +1179,9 @@ export class RelayPool {
|
|
|
1171
1179
|
;(async () => {
|
|
1172
1180
|
try {
|
|
1173
1181
|
const relay = await this.#getRelay(url)
|
|
1174
|
-
|
|
1182
|
+
const signal = sendControllers[index].signal
|
|
1183
|
+
if (signal.aborted) throw signal.reason
|
|
1184
|
+
return await this.#publishEvent(relay, eventToSend, getAuthEvent, signal)
|
|
1175
1185
|
} catch (err) {
|
|
1176
1186
|
const reason = err instanceof Error ? err : new Error(String(err))
|
|
1177
1187
|
if (reason instanceof Nip42AuthenticationError) throw reason
|
|
@@ -36,8 +36,9 @@ export function createTemporaryStorage ({ storageArea = globalThis.sessionStorag
|
|
|
36
36
|
|
|
37
37
|
function trackTemporaryKey (key) {
|
|
38
38
|
const tracked = readTrackedKeys()
|
|
39
|
-
if (tracked.includes(key)) return
|
|
39
|
+
if (tracked.includes(key)) return false
|
|
40
40
|
writeTrackedKeys(tracked.concat(key))
|
|
41
|
+
return true
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
function untrackTemporaryKeys (keys) {
|
|
@@ -57,8 +58,14 @@ export function createTemporaryStorage ({ storageArea = globalThis.sessionStorag
|
|
|
57
58
|
|
|
58
59
|
function setItem (key, value) {
|
|
59
60
|
if (typeof key !== 'string' || !key || key === TEMPORARY_STORAGE_KEYS_KEY) throw new ValidationError('INVALID_TEMPORARY_STORAGE_KEY')
|
|
60
|
-
trackTemporaryKey(key)
|
|
61
|
-
|
|
61
|
+
const newlyTracked = trackTemporaryKey(key)
|
|
62
|
+
try {
|
|
63
|
+
storage().setItem(key, value)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// A failed write is atomic; keep tracking any previous value.
|
|
66
|
+
if (newlyTracked && storage().getItem(key) === null) untrackTemporaryKeys([key])
|
|
67
|
+
throw error
|
|
68
|
+
}
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
function removeItems (keys) {
|