libp2r2p 0.11.6 → 0.11.7
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 +21 -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 +45 -50
- package/private-messenger/recovery/index.js +21 -8
- package/relay/helpers/error.js +12 -0
- package/relay/services/relay-connection.js +69 -20
- package/relay/services/relay-pool.js +9 -5
- 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,23 @@ 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.
|
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']) {
|
|
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
|
|
|
@@ -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
|
+
}
|
|
@@ -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)
|
|
@@ -233,21 +233,21 @@ export class RelayPool {
|
|
|
233
233
|
|
|
234
234
|
// NIP-42 retries happen inside one relay attempt, so sendEvent still reports
|
|
235
235
|
// exactly one terminal outcome for each relay URL.
|
|
236
|
-
async #publishEvent (relay, event, getAuthEvent) {
|
|
236
|
+
async #publishEvent (relay, event, getAuthEvent, signal) {
|
|
237
237
|
try {
|
|
238
|
-
await relay.publish(event)
|
|
238
|
+
await relay.publish(event, { signal })
|
|
239
239
|
return 'published'
|
|
240
240
|
} catch (error) {
|
|
241
241
|
const reason = error instanceof Error ? error : new Error(String(error))
|
|
242
242
|
if (!getAuthEvent || !requiresNip42Auth(reason)) throw reason
|
|
243
243
|
|
|
244
244
|
try {
|
|
245
|
-
await relay.authenticate(getAuthEvent)
|
|
245
|
+
await relay.authenticate(getAuthEvent, { signal })
|
|
246
246
|
} catch (error) {
|
|
247
247
|
const authReason = error instanceof Error ? error : new Error(String(error))
|
|
248
248
|
throw new Nip42AuthenticationError(authReason)
|
|
249
249
|
}
|
|
250
|
-
await relay.publish(event)
|
|
250
|
+
await relay.publish(event, { signal })
|
|
251
251
|
return 'published'
|
|
252
252
|
}
|
|
253
253
|
}
|
|
@@ -1144,12 +1144,14 @@ export class RelayPool {
|
|
|
1144
1144
|
const eventToSend = event.meta ? { ...event } : event
|
|
1145
1145
|
if (eventToSend.meta) delete eventToSend.meta
|
|
1146
1146
|
|
|
1147
|
+
const sendControllers = urls.map(() => new AbortController())
|
|
1147
1148
|
const sendDeferreds = urls.map(() => Promise.withResolvers())
|
|
1148
1149
|
const sendPromises = sendDeferreds.map(({ promise }) => promise)
|
|
1149
1150
|
|
|
1150
1151
|
// Starts before connection work so every relay shares one real deadline.
|
|
1151
1152
|
const settlement = createPublishSettlements(sendPromises, timeout, {
|
|
1152
1153
|
onSettled: (settlement, index) => {
|
|
1154
|
+
sendControllers[index].abort(settlement.reason)
|
|
1153
1155
|
if (settlement.reason?.category === 'timeout' && !settlement.reason.cause) {
|
|
1154
1156
|
const relay = this.#relays.get(normalizeRelayUrl(urls[index]))
|
|
1155
1157
|
if (relay?.lastTransportError) settlement.reason.cause = relay.lastTransportError
|
|
@@ -1171,7 +1173,9 @@ export class RelayPool {
|
|
|
1171
1173
|
;(async () => {
|
|
1172
1174
|
try {
|
|
1173
1175
|
const relay = await this.#getRelay(url)
|
|
1174
|
-
|
|
1176
|
+
const signal = sendControllers[index].signal
|
|
1177
|
+
if (signal.aborted) throw signal.reason
|
|
1178
|
+
return await this.#publishEvent(relay, eventToSend, getAuthEvent, signal)
|
|
1175
1179
|
} catch (err) {
|
|
1176
1180
|
const reason = err instanceof Error ? err : new Error(String(err))
|
|
1177
1181
|
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) {
|