libp2r2p 0.11.36 → 0.12.1

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.
@@ -56,6 +56,7 @@ import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery
56
56
  import { assertMessagePublished, publicationFailureDiagnostic, sanitizePublicationError } from './helpers/publication.js'
57
57
  import { normalizeRelayUrl } from '../url/index.js'
58
58
  import { deliveryInfo } from '../private-channel/helpers/rumor.js'
59
+ import { normalizePreparedPublications } from '../private-channel/helpers/prepared.js'
59
60
  import { migrateMessageQueue } from './services/migrate-message-queue.js'
60
61
  import { createPendingDecryption, pendingContentKeyGroup, pendingMissingContentKeyGroup } from './services/pending-decryption.js'
61
62
  import { createDecryptionScheduler } from './services/decryption-scheduler.js'
@@ -2415,18 +2416,22 @@ export class PrivateMessenger {
2415
2416
  } finally { releaseActivity() }
2416
2417
  }
2417
2418
 
2418
- async yell ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey }) {
2419
+ async yell ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, message, code, payload, error, content, deletionPubkey, createdAt, onPreparedSeed, preparedPublication, preparedPublications }) {
2419
2420
  const channel = this.requireWritableChannel(channelPubkey)
2420
2421
  const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2421
2422
  try {
2422
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers })
2423
- this.debugSend('yell', channelPubkey, { code, receiverPubkeys })
2423
+ const prepared = normalizePreparedPublications({ preparedPublication, preparedPublications })
2424
+ const receivers = uniq(receiverPubkeys?.length
2425
+ ? receiverPubkeys
2426
+ : prepared.flatMap(publication => publication.receiverPubkeys || []))
2427
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys: receivers, relays, relayToReceivers })
2428
+ this.debugSend('yell', channelPubkey, { code, receiverPubkeys: receivers })
2424
2429
  return await this._privateMessage.yell({
2425
2430
  senderSigner: this.userSigner,
2426
2431
  imkcSigner: this.contentKeySigner,
2427
2432
  privateChannelSigner: channel.signer,
2428
2433
  privateChannelReaderPubkey: channel.readerPubkey,
2429
- receiverPubkeys,
2434
+ receiverPubkeys: receivers,
2430
2435
  ...routing,
2431
2436
  expirationSeconds: this.eventExpirationSecondsFor(channel),
2432
2437
  temporaryStorageArea: this.temporaryStorageArea,
@@ -2437,24 +2442,30 @@ export class PrivateMessenger {
2437
2442
  payload,
2438
2443
  error,
2439
2444
  content,
2440
- onPreparedSeed: this.outgoingSeedHandler(channelPubkey),
2445
+ createdAt,
2446
+ onPreparedSeed: onPreparedSeed || this.outgoingSeedHandler(channelPubkey),
2447
+ preparedPublications: prepared.length ? prepared : undefined,
2441
2448
  _getIykcProofs: this.contentKeyLookup()
2442
2449
  })
2443
2450
  } finally { releaseActivity() }
2444
2451
  }
2445
2452
 
2446
- async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug }) {
2453
+ async broadcastRumor ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, rumor, deletionPubkey, signal, attemptStartedAt, onPreparedSeed, preparedPublication, preparedPublications, onDebug }) {
2447
2454
  const channel = this.requireWritableChannel(channelPubkey)
2448
2455
  const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2449
2456
  try {
2450
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug })
2451
- this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys })
2457
+ const prepared = normalizePreparedPublications({ preparedPublication, preparedPublications })
2458
+ const receivers = uniq(receiverPubkeys?.length
2459
+ ? receiverPubkeys
2460
+ : prepared.flatMap(publication => publication.receiverPubkeys || []))
2461
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys: receivers, relays, relayToReceivers, signal, attemptStartedAt, onDebug })
2462
+ this.debugSend('broadcastRumor', channelPubkey, { receiverPubkeys: receivers })
2452
2463
  return await this._privateMessage.broadcastRumor({
2453
2464
  senderSigner: this.userSigner,
2454
2465
  imkcSigner: this.contentKeySigner,
2455
2466
  privateChannelSigner: channel.signer,
2456
2467
  privateChannelReaderPubkey: channel.readerPubkey,
2457
- receiverPubkeys,
2468
+ receiverPubkeys: receivers,
2458
2469
  ...routing,
2459
2470
  expirationSeconds: this.eventExpirationSecondsFor(channel),
2460
2471
  temporaryStorageArea: this.temporaryStorageArea,
@@ -2462,24 +2473,28 @@ export class PrivateMessenger {
2462
2473
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2463
2474
  rumor,
2464
2475
  onPreparedSeed: onPreparedSeed || this.outgoingSeedHandler(channelPubkey),
2465
- preparedPublication,
2476
+ preparedPublications: prepared.length ? prepared : undefined,
2466
2477
  _getIykcProofs: this.contentKeyLookup()
2467
2478
  })
2468
2479
  } finally { releaseActivity() }
2469
2480
  }
2470
2481
 
2471
- async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug }) {
2482
+ async broadcastEvent ({ channelPubkey = this.defaultChannelPubkey(), receiverPubkeys, relays, relayToReceivers, event, deletionPubkey, signal, attemptStartedAt, onPreparedSeed, preparedPublication, preparedPublications, onDebug }) {
2472
2483
  const channel = this.requireWritableChannel(channelPubkey)
2473
2484
  const releaseActivity = this.decryptionScheduler.activity(channelPubkey)
2474
2485
  try {
2475
- const routing = await this.resolveSendRouting({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug })
2476
- this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys })
2486
+ const prepared = normalizePreparedPublications({ preparedPublication, preparedPublications })
2487
+ const receivers = uniq(receiverPubkeys?.length
2488
+ ? receiverPubkeys
2489
+ : prepared.flatMap(publication => publication.receiverPubkeys || []))
2490
+ const routing = await this.resolveSendRouting({ channel, receiverPubkeys: receivers, relays, relayToReceivers, signal, attemptStartedAt, onDebug })
2491
+ this.debugSend('broadcastEvent', channelPubkey, { receiverPubkeys: receivers })
2477
2492
  return await this._privateMessage.broadcastEvent({
2478
2493
  senderSigner: this.userSigner,
2479
2494
  imkcSigner: this.contentKeySigner,
2480
2495
  privateChannelSigner: channel.signer,
2481
2496
  privateChannelReaderPubkey: channel.readerPubkey,
2482
- receiverPubkeys,
2497
+ receiverPubkeys: receivers,
2483
2498
  ...routing,
2484
2499
  expirationSeconds: this.eventExpirationSecondsFor(channel),
2485
2500
  temporaryStorageArea: this.temporaryStorageArea,
@@ -2487,7 +2502,7 @@ export class PrivateMessenger {
2487
2502
  autoDeletionCapability: this.autoDeletionCapabilityFor(channel),
2488
2503
  event,
2489
2504
  onPreparedSeed: onPreparedSeed || this.outgoingSeedHandler(channelPubkey),
2490
- preparedPublication,
2505
+ preparedPublications: prepared.length ? prepared : undefined,
2491
2506
  _getIykcProofs: this.contentKeyLookup()
2492
2507
  })
2493
2508
  } finally { releaseActivity() }
@@ -289,9 +289,17 @@ recipient/channel and inner-event identities, event/chunk position, retention
289
289
  and accepted/archived flags. Old entries without it retain `localSaved`, index
290
290
  and file/chunk progress and enter the same preparation path; there is no database
291
291
  migration or alternate publisher. Replays validate signatures and identity and
292
- reuse the exact envelopes without repeating cryptography or extending expiry.
293
-
294
- The existing `onPreparedSeed` callback now also supplies `publication`. The
292
+ reuse the exact envelopes without repeating cryptography or extending expiry,
293
+ except for one 3560 age-auth refresh. That refresh reuses router/ciphertext and
294
+ seeds but re-signs the whole publication with a new shared `created_at` and
295
+ recomputed `expiration`, writes the replacement generation before retrying, and
296
+ sets `outerRefreshAttempted` so reload cannot repeat it. A relay may report a
297
+ stale wrapper as `invalid:`; that prefix permits this refresh but does not
298
+ attempt NIP-42 authentication.
299
+
300
+ The existing `onPreparedSeed` callback now also supplies `publication`, plus an
301
+ optional `{ replace: true }` second argument when a refreshed publication must
302
+ replace the same inner-event/receiver group in place. The
295
303
  session commits that minimal journal before transmitting; direct messengers
296
304
  retain their local seed-store durability. Only after that commit can relay
297
305
  publication proceed. One independent serial worker converts recovery seeds into
@@ -321,6 +329,12 @@ relay confirmation separately from `localSaved`. Old entries normalize from
321
329
  saved delivery status, never from local progress; their event/file indices and
322
330
  prepared envelopes remain unchanged. No database migration is needed.
323
331
 
332
+ Prepared envelopes are stored as the `prepared.publications` array. Older
333
+ snapshots containing `prepared.publication` are converted to a one-item array
334
+ and rewritten through the same encrypted outbox queue before publication or
335
+ archive work resumes. The singular key is never written by current versions and
336
+ is not accepted as a public replay argument.
337
+
324
338
  `onOutbox` adds `localPersistence: { state, retryable }`, where state is pending,
325
339
  saved or failed. It is a presentation summary with no native error objects.
326
340
  A saved remote entry may still have pending/failed local work. Keep it visible
@@ -0,0 +1,17 @@
1
+ import { ValidationError } from '../../../error/index.js'
2
+
3
+ export function normalizePreparedState (prepared) {
4
+ if (!prepared) return { prepared, migrated: false }
5
+ const legacy = prepared.publication
6
+ const publications = prepared.publications !== undefined
7
+ ? prepared.publications
8
+ : legacy !== undefined
9
+ ? [legacy]
10
+ : null
11
+ if (!Array.isArray(publications) || publications.length === 0) {
12
+ throw new ValidationError('INVALID_PREPARED_PUBLICATIONS')
13
+ }
14
+ prepared.publications = publications
15
+ delete prepared.publication
16
+ return { prepared, migrated: legacy !== undefined }
17
+ }
@@ -24,7 +24,7 @@ export function createPublicationJournal ({ entries, store, archive, changed, on
24
24
  function rows (entry) {
25
25
  const packet = entry.prepared
26
26
  if (!packet?.recover) return []
27
- return packet.publication.seeds.flatMap(seed => compactSeedRouterRows(seed).map(row => {
27
+ return packet.publications.flatMap(publication => publication.seeds.flatMap(seed => compactSeedRouterRows(seed).map(row => {
28
28
  const descriptor = packet.target.file
29
29
  const value = {
30
30
  ...row, channelPubkey: seed.channelPubkey, receivedAt: packet.receivedAt,
@@ -32,15 +32,15 @@ export function createPublicationJournal ({ entries, store, archive, changed, on
32
32
  ...(descriptor
33
33
  ? {
34
34
  root: descriptor.root, controlChannelPubkey: descriptor.controlChannelPubkey,
35
- fileChannelPubkey: packet.publication.channelPubkey, peerPubkey: descriptor.peerPubkey,
35
+ fileChannelPubkey: publication.channelPubkey, peerPubkey: descriptor.peerPubkey,
36
36
  chunkIndex: packet.target.chunkIndex,
37
- key: `${descriptor.controlChannelPubkey}:${packet.publication.channelPubkey}:${row.receiverPubkey}:${packet.target.chunkIndex}`
37
+ key: `${descriptor.controlChannelPubkey}:${publication.channelPubkey}:${row.receiverPubkey}:${packet.target.chunkIndex}`
38
38
  }
39
39
  : {})
40
40
  }
41
41
  value.recordId = encodeRecord('seed', value).tags[0][1]
42
42
  return value
43
- }))
43
+ })))
44
44
  }
45
45
  function retryLater () {
46
46
  if (stopped || !enabled || timer || !failed.size) return
@@ -60,7 +60,7 @@ export function createPublicationJournal ({ entries, store, archive, changed, on
60
60
  try {
61
61
  if (!durable.has(packet)) { await write(entry); durable.add(packet) }
62
62
  if (!packet.archived) {
63
- debug(entry, 'recovery-archive-start', queued.get(packet), { publicationId: packet.publication.events[0].id })
63
+ debug(entry, 'recovery-archive-start', queued.get(packet), { publicationId: packet.publications[0].events[0].id })
64
64
  if (packet.recover) await archive(entry, rows(entry))
65
65
  packet.archived = true
66
66
  debug(entry, 'recovery-archived', started)
@@ -87,15 +87,43 @@ export function createPublicationJournal ({ entries, store, archive, changed, on
87
87
  write,
88
88
  async flush (entry) { while (writes.has(entry.id)) await writes.get(entry.id) },
89
89
  adopt (entry) { if (entry.prepared) { durable.add(entry.prepared); queued.set(entry.prepared, Date.now()) } },
90
- async stage (entry, seed, target, retentionSeconds) {
90
+ async stage (entry, seed, target, retentionSeconds, { replace = false } = {}) {
91
91
  const started = Date.now()
92
- if (entry.prepared && entry.prepared.publication.events[0].id !== seed.publication.events[0].id) throw new Error('PUBLICATION_ARCHIVE_PENDING')
93
- entry.prepared ||= { publication: structuredClone(seed.publication), target, receivedAt: Math.floor(Date.now() / 1000), retentionSeconds, recover: retentionSeconds > 0, accepted: false, archived: retentionSeconds <= 0 }
94
- if (durable.has(entry.prepared)) return
92
+ const publication = structuredClone(seed.publication)
93
+ const targetKey = JSON.stringify(target)
94
+ entry.prepared ||= { publications: [], target, receivedAt: Math.floor(Date.now() / 1000), retentionSeconds, recover: retentionSeconds > 0, accepted: false, archived: retentionSeconds <= 0 }
95
+ if (replace && targetKey !== JSON.stringify(entry.prepared.target)) throw new Error('PUBLICATION_ARCHIVE_TARGET_MISMATCH')
96
+ if (entry.prepared.publications.some(existing => existing.innerEventId !== publication.innerEventId)) {
97
+ throw new Error('PUBLICATION_ARCHIVE_PENDING')
98
+ }
99
+ const receiverKey = (publication.receiverPubkeys || []).slice().sort().join(',')
100
+ const eventKey = publication.events.map(event => event.id).join(',')
101
+ const matchingIndexes = entry.prepared.publications
102
+ .map((existing, index) => ({ existing, index }))
103
+ .filter(({ existing }) => existing.innerEventId === publication.innerEventId &&
104
+ (existing.receiverPubkeys || []).slice().sort().join(',') === receiverKey)
105
+ const existingIndex = matchingIndexes[0]?.index ?? -1
106
+ const existing = matchingIndexes[0]?.existing
107
+ const duplicate = Boolean(existing) &&
108
+ existing.events.map(event => event.id).join(',') === eventKey
109
+ const matches = existingIndex >= 0
110
+ if (replace) {
111
+ if (!matches) throw new Error('PUBLICATION_ARCHIVE_REPLACE_MISSING')
112
+ entry.prepared.publications = entry.prepared.publications
113
+ .filter((_publication, index) => index !== existingIndex)
114
+ .filter(candidate =>
115
+ candidate.innerEventId !== publication.innerEventId ||
116
+ (candidate.receiverPubkeys || []).slice().sort().join(',') !== receiverKey
117
+ )
118
+ entry.prepared.publications.splice(existingIndex, 0, publication)
119
+ } else {
120
+ if (!duplicate) entry.prepared.publications.push(publication)
121
+ if (duplicate && durable.has(entry.prepared)) return
122
+ }
95
123
  queued.set(entry.prepared, Date.now())
96
124
  await write(entry)
97
125
  durable.add(entry.prepared)
98
- debug(entry, 'publication-checkpoint', started, { publicationId: seed.publication.events[0].id })
126
+ debug(entry, replace ? 'publication-refresh' : 'publication-checkpoint', started, { publicationId: seed.publication.events[0].id })
99
127
  for (const row of rows(entry)) debug(entry, 'recovery-pending', undefined, { seedRecordId: row.recordId, publicationId: seed.publication.events[0].id })
100
128
  wake()
101
129
  },
@@ -16,6 +16,7 @@ import { assertMessagePublished } from '../helpers/publication.js'
16
16
  import { normalizeFallbackRelays, normalizeFallbackDelay } from '../helpers/send-routing.js'
17
17
  import { createPublicationJournal, withPendingSeeds } from './helpers/publication-journal.js'
18
18
  import { createPauseRecovery, isRecoverableStorageFailure } from '../helpers/pause-recovery.js'
19
+ import { normalizePreparedState } from './helpers/prepared-state.js'
19
20
 
20
21
  export function wireEvent (value, owner) {
21
22
  const event = { kind: value.kind, created_at: value.created_at, tags: structuredClone(value.tags), content: value.content, pubkey: value.pubkey || owner }
@@ -164,6 +165,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
164
165
  const idleWaiters = new Set()
165
166
  const settled = () => { if (!sending && !localSaving && !enqueuing && !draining && !cleanups.size) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
166
167
  const localComplete = entry => entry.localSaved.every(Boolean)
168
+ const preparedPublicationId = entry => entry.prepared?.publications?.[0]?.events?.[0]?.id
167
169
  const completed = entry => !unadmitted.has(entry) && localComplete(entry) && (entry.peer === owner || entry.deliveryConfirmed)
168
170
  const emit = () => onOutbox([...entries.values()].filter(entry => !unadmitted.has(entry) && !entry.deletion && !entry.cancelled).map(entry => ({
169
171
  ...entry,
@@ -188,7 +190,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
188
190
  const onDiagnostic = details => {
189
191
  diagnosticReported = true
190
192
  debug(entry, 'recovery-archive-result', archiveStartedAt, {
191
- publicationId: entry.prepared.publication.events[0].id,
193
+ publicationId: preparedPublicationId(entry),
192
194
  ...details
193
195
  })
194
196
  }
@@ -199,15 +201,19 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
199
201
  for (const row of rows) archived.push(await recoveryStorage.seeds.put(row))
200
202
  }
201
203
  } else if (entry.prepared.target.file) {
202
- for (const seed of entry.prepared.publication.seeds) await fileTransfers.archiveSeed(entry.prepared.target.file, seed)
204
+ for (const publication of entry.prepared.publications) {
205
+ for (const seed of publication.seeds) await fileTransfers.archiveSeed(entry.prepared.target.file, seed)
206
+ }
203
207
  } else {
204
- for (const seed of entry.prepared.publication.seeds) await messenger.enqueueSeed(seed.channelPubkey, seed)
208
+ for (const publication of entry.prepared.publications) {
209
+ for (const seed of publication.seeds) await messenger.enqueueSeed(seed.channelPubkey, seed)
210
+ }
205
211
  }
206
212
  if (!diagnosticReported) {
207
213
  const results = Array.isArray(archived) ? archived : []
208
214
  const storedCount = results.filter(Boolean).length
209
215
  debug(entry, 'recovery-archive-result', archiveStartedAt, {
210
- publicationId: entry.prepared.publication.events[0].id,
216
+ publicationId: preparedPublicationId(entry),
211
217
  rowCount: rows.length,
212
218
  uniqueCount: new Set(rows.map(row => row.recordId).filter(Boolean)).size,
213
219
  storedCount,
@@ -226,6 +232,9 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
226
232
  const ready = () => (initialized ??= (async () => {
227
233
  storage = await openOutbox({ owner, signer, onDebug })
228
234
  for (const entry of await storage.list()) {
235
+ if (normalizePreparedState(entry.prepared).migrated) {
236
+ await storage.put(structuredClone(entry), { existing: true })
237
+ }
229
238
  // Old encrypted entries retain progress and enter this same pipeline.
230
239
  const legacyLocalFailure = entry.deliveryConfirmed === undefined && entry.failed && !entry.localSaved.every(Boolean)
231
240
  if (legacyLocalFailure) {
@@ -471,10 +480,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
471
480
  const options = {
472
481
  channelPubkey, receiverPubkeys: [peer], signal, attemptStartedAt: attemptStarts.get(entry.id),
473
482
  onDebug: onDebug ? value => debug(entry, value.stage, undefined, value) : undefined,
474
- preparedPublication: entry.prepared?.publication,
475
- onPreparedSeed: async seed => {
483
+ preparedPublications: entry.prepared?.publications,
484
+ onPreparedSeed: async (seed, { replace = false } = {}) => {
476
485
  debug(entry, 'envelope-prepared', preparationStartedAt, { publicationId: seed.publication.events[0].id })
477
- await journal.stage(entry, seed, { index: entry.index }, retention)
486
+ await journal.stage(entry, seed, { index: entry.index }, retention, { replace })
478
487
  signal.throwIfAborted()
479
488
  }
480
489
  }
@@ -592,8 +601,11 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
592
601
  const recover = mode === 'watchtower' ? messenger.offlineRecoverySecondsFor?.(descriptor.controlChannelPubkey) ?? 0 : 0
593
602
  const target = { file: descriptor, fileIndex: entry.fileIndex, chunkIndex: entry.chunkIndex }
594
603
  await fileTransfers.publishChunk(descriptor, wireEvent(chunk, owner), {
595
- signal: controller.signal, attemptStartedAt: chunkStartedAt, onDebug: onDebug ? value => debug(entry, value.stage, undefined, value) : undefined, preparedPublication: entry.prepared?.publication,
596
- onPreparedSeed: async seed => { await journal.stage(entry, seed, target, recover); controller.signal.throwIfAborted() }
604
+ signal: controller.signal, attemptStartedAt: chunkStartedAt, onDebug: onDebug ? value => debug(entry, value.stage, undefined, value) : undefined, preparedPublications: entry.prepared?.publications,
605
+ onPreparedSeed: async (seed, { replace = false } = {}) => {
606
+ await journal.stage(entry, seed, target, recover, { replace })
607
+ controller.signal.throwIfAborted()
608
+ }
597
609
  })
598
610
  entry.chunkIndex++
599
611
  const completed = entry.files.slice(0, entry.fileIndex).reduce((sum, value) => sum + value.size, 0) + Math.min(file.size, entry.chunkIndex * IRFS_CHUNK_BYTES)
@@ -8,6 +8,7 @@ import { maybeUnref } from '../helpers/timer.js'
8
8
  import { categorizeRelayError, relayTimeoutError } from '../helpers/error.js'
9
9
  import { ReadRetry, isRetryableReadFailure, needsConnectivityCheck, waitUntil } from '../helpers/read-retry.js'
10
10
  import { normalizeRelayUrl } from '../../url/index.js'
11
+ import { isValidEvent } from '../../event/index.js'
11
12
  import { RelayConnection } from './relay-connection.js'
12
13
 
13
14
  const CONNECTION_TIMEOUT_MS = 3000
@@ -69,7 +70,19 @@ function notifyRelayResult (onRelayResult, result) {
69
70
  }
70
71
 
71
72
  function requiresNip42Auth (reason) {
72
- return reason.message.startsWith('auth-required:') || reason.message.startsWith('restricted:')
73
+ const message = reason?.message || reason?.code || ''
74
+ return message.startsWith('auth-required:') || message.startsWith('restricted:')
75
+ }
76
+
77
+ function requiresOuterRefresh (reason) {
78
+ return requiresNip42Auth(reason) || (reason?.message || reason?.code || '').startsWith('invalid:')
79
+ }
80
+
81
+ function isDefinitiveOuterRejection (report) {
82
+ return report?.success === false &&
83
+ Array.isArray(report.errors) &&
84
+ report.errors.length > 0 &&
85
+ report.errors.every(({ reason }) => requiresOuterRefresh(reason))
73
86
  }
74
87
 
75
88
  function countResponseError () {
@@ -1199,15 +1212,19 @@ export class RelayPool {
1199
1212
  // { relay, success, outcome, reason? } result per relay as it settles; outcome
1200
1213
  // is published, duplicate, muted, failed, or timed-out. getAuthEvent is used
1201
1214
  // only after auth-required or restricted publish failures, then retries once.
1215
+ // getRetryEvent may return one fresh signed event after every relay rejected
1216
+ // the original with auth-required/restricted/invalid; it shares the operation deadline.
1202
1217
  // Await `promise` for the complete report, including every relay outcome.
1203
1218
  async sendEvent (event, relays, {
1204
1219
  timeout = SEND_TIMEOUT_MS,
1205
1220
  timeoutUntilFirstFulfillment = null,
1206
1221
  getAuthEvent,
1222
+ getRetryEvent,
1207
1223
  onRelayResult,
1208
1224
  signal
1209
1225
  } = {}) {
1210
1226
  if (signal != null && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function' || typeof signal.removeEventListener !== 'function')) throw new ValidationError('INVALID_RELAY_SIGNAL')
1227
+ if (getRetryEvent != null && typeof getRetryEvent !== 'function') throw new ValidationError('INVALID_RETRY_EVENT_CALLBACK')
1211
1228
  const urls = normalizedRelayUrls(relays)
1212
1229
  if (!urls.length) {
1213
1230
  const promise = Promise.resolve(publishSummary([], urls, {
@@ -1216,69 +1233,121 @@ export class RelayPool {
1216
1233
  return { total: 0, success: false, promise }
1217
1234
  }
1218
1235
 
1219
- const eventToSend = event.meta ? { ...event } : event
1220
- if (eventToSend.meta) delete eventToSend.meta
1221
-
1222
- const sendControllers = urls.map(() => new AbortController())
1223
- const sendDeferreds = urls.map(() => Promise.withResolvers())
1224
- const sendPromises = sendDeferreds.map(({ promise }) => promise)
1225
-
1226
- // Starts before connection work so every relay shares one real deadline.
1227
- const settlement = createPublishSettlements(sendPromises, timeout, {
1228
- onSettled: (settlement, index) => {
1229
- sendControllers[index].abort(settlement.reason)
1230
- if (settlement.reason?.category === 'timeout' && !settlement.reason.cause) {
1231
- const relay = this.#relays.get(normalizeRelayUrl(urls[index]))
1232
- if (relay?.lastTransportError) settlement.reason.cause = relay.lastTransportError
1236
+ const deadline = timeout === null ? null : Date.now() + timeout
1237
+ const remainingTimeout = () => deadline === null ? null : Math.max(0, deadline - Date.now())
1238
+ const sendAttempt = async (eventToSend, { notify = true } = {}) => {
1239
+ const attemptTimeout = remainingTimeout()
1240
+ const eventForAttempt = eventToSend.meta ? { ...eventToSend } : eventToSend
1241
+ if (eventForAttempt.meta) delete eventForAttempt.meta
1242
+ const sendControllers = urls.map(() => new AbortController())
1243
+ const sendDeferreds = urls.map(() => Promise.withResolvers())
1244
+ const sendPromises = sendDeferreds.map(({ promise }) => promise)
1245
+ const bufferedResults = []
1246
+ const delivery = { notify }
1247
+
1248
+ // Starts before connection work so every relay shares one real deadline.
1249
+ const settlement = createPublishSettlements(sendPromises, attemptTimeout, {
1250
+ onSettled: (settlement, index) => {
1251
+ sendControllers[index].abort(settlement.reason)
1252
+ if (settlement.reason?.category === 'timeout' && !settlement.reason.cause) {
1253
+ const relay = this.#relays.get(normalizeRelayUrl(urls[index]))
1254
+ if (relay?.lastTransportError) settlement.reason.cause = relay.lastTransportError
1255
+ }
1256
+ const result = relayResultForSettlement(urls[index], settlement)
1257
+ if (delivery.notify) notifyRelayResult(onRelayResult, result)
1258
+ else bufferedResults.push(result)
1233
1259
  }
1234
- notifyRelayResult(onRelayResult, relayResultForSettlement(urls[index], settlement))
1235
- }
1236
- })
1237
-
1238
- // Resolves after every relay settles (or reaches the operation timeout) as
1239
- // { success, total, fulfilled, succeededRelays, errors },
1240
- // where errors contains { relay, reason } entries for failed relays.
1241
- const cancel = () => settlement.cancel(signal.reason)
1242
- if (signal?.aborted) cancel()
1243
- else signal?.addEventListener('abort', cancel, { once: true })
1244
- const promise = settlement.promise
1245
- .finally(() => signal?.removeEventListener('abort', cancel))
1246
- .then(settlements => publishSummary(settlements, urls, {
1247
- includeSucceededRelays: true
1248
- }))
1260
+ })
1249
1261
 
1250
- urls.forEach((url, index) => {
1251
- const deferred = sendDeferreds[index]
1252
- ;(async () => {
1253
- try {
1254
- const operationSignal = sendControllers[index].signal
1255
- if (operationSignal.aborted) throw operationSignal.reason
1256
- const relay = await this.#getRelay(url)
1257
- if (operationSignal.aborted) throw operationSignal.reason
1258
- return await this.#publishEvent(relay, eventToSend, getAuthEvent, operationSignal)
1259
- } catch (err) {
1260
- const reason = err instanceof Error ? err : new Error(String(err))
1261
- if (reason instanceof Nip42AuthenticationError) throw reason
1262
- if (reason.message.startsWith('duplicate:')) return 'duplicate'
1263
- if (reason.message.startsWith('mute:')) {
1264
- console.info([url, reason.message].filter(Boolean).join(' - '))
1265
- return 'muted'
1262
+ // Resolves after every relay settles (or reaches the operation timeout) as
1263
+ // { success, total, fulfilled, succeededRelays, errors },
1264
+ // where errors contains { relay, reason } entries for failed relays.
1265
+ const cancel = () => settlement.cancel(signal.reason)
1266
+ if (signal?.aborted) cancel()
1267
+ else signal?.addEventListener('abort', cancel, { once: true })
1268
+ const promise = settlement.promise
1269
+ .finally(() => signal?.removeEventListener('abort', cancel))
1270
+ .then(settlements => publishSummary(settlements, urls, {
1271
+ includeSucceededRelays: true
1272
+ }))
1273
+
1274
+ urls.forEach((url, index) => {
1275
+ const deferred = sendDeferreds[index]
1276
+ ;(async () => {
1277
+ try {
1278
+ const operationSignal = sendControllers[index].signal
1279
+ if (operationSignal.aborted) throw operationSignal.reason
1280
+ const relay = await this.#getRelay(url)
1281
+ if (operationSignal.aborted) throw operationSignal.reason
1282
+ return await this.#publishEvent(relay, eventForAttempt, getAuthEvent, operationSignal)
1283
+ } catch (err) {
1284
+ const reason = err instanceof Error ? err : new Error(String(err))
1285
+ if (reason instanceof Nip42AuthenticationError) throw reason
1286
+ if (reason.message.startsWith('duplicate:')) return 'duplicate'
1287
+ if (reason.message.startsWith('mute:')) {
1288
+ console.info([url, reason.message].filter(Boolean).join(' - '))
1289
+ return 'muted'
1290
+ }
1291
+ throw reason
1266
1292
  }
1267
- throw reason
1268
- }
1269
- })().then(deferred.resolve, deferred.reject)
1270
- })
1293
+ })().then(deferred.resolve, deferred.reject)
1294
+ })
1271
1295
 
1272
- const success = await firstFulfillment(sendPromises, timeoutUntilFirstFulfillment, {
1273
- fallback: promise.then(report => report.success)
1274
- })
1275
- if (!success) settlement.timeout()
1296
+ const earlyTimeout = timeoutUntilFirstFulfillment === null || attemptTimeout === null
1297
+ ? timeoutUntilFirstFulfillment
1298
+ : Math.min(timeoutUntilFirstFulfillment, attemptTimeout)
1299
+ const success = await firstFulfillment(sendPromises, earlyTimeout, {
1300
+ fallback: promise.then(report => report.success)
1301
+ })
1302
+ if (!success) settlement.timeout()
1276
1303
 
1277
- return {
1278
- total: urls.length,
1279
- success,
1280
- promise
1304
+ const result = {
1305
+ total: urls.length,
1306
+ success,
1307
+ promise
1308
+ }
1309
+ Object.defineProperty(result, 'flushRelayResults', {
1310
+ value: () => {
1311
+ delivery.notify = true
1312
+ for (const result of bufferedResults.splice(0)) notifyRelayResult(onRelayResult, result)
1313
+ }
1314
+ })
1315
+ return result
1316
+ }
1317
+
1318
+ const first = await sendAttempt(event, { notify: !getRetryEvent })
1319
+ if (first.success || !getRetryEvent) {
1320
+ first.flushRelayResults()
1321
+ return first
1322
+ }
1323
+ let report
1324
+ try {
1325
+ report = await first.promise
1326
+ } catch {
1327
+ first.flushRelayResults()
1328
+ return first
1329
+ }
1330
+ if (!isDefinitiveOuterRejection(report) || (deadline !== null && Date.now() >= deadline)) {
1331
+ first.flushRelayResults()
1332
+ return first
1333
+ }
1334
+ let retryEvent
1335
+ try {
1336
+ retryEvent = await getRetryEvent({ event, report })
1337
+ } catch (cause) {
1338
+ throw Object.assign(new Error('RETRY_EVENT_PREPARATION_FAILED', { cause }), {
1339
+ code: 'RETRY_EVENT_PREPARATION_FAILED',
1340
+ cause
1341
+ })
1342
+ }
1343
+ if ((deadline !== null && Date.now() >= deadline) ||
1344
+ !isValidEvent(retryEvent) || retryEvent.id === event.id || retryEvent.kind !== event.kind) {
1345
+ first.flushRelayResults()
1346
+ return first
1281
1347
  }
1348
+ const retried = await sendAttempt(retryEvent, { notify: true })
1349
+ Object.defineProperty(retried, 'retriedEvent', { value: retryEvent })
1350
+ return retried
1282
1351
  }
1283
1352
  }
1284
1353