libp2r2p 0.11.24 → 0.11.26

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 CHANGED
@@ -10,7 +10,8 @@ power-ups used by that messenger.
10
10
 
11
11
  For remote-work scheduling, see [`libp2r2p/network`](network/README.md):
12
12
  `isOnline` probes connectivity and `onOnline` shares recovery monitoring,
13
- including retries when the browser omits its native `online` event.
13
+ including retries when the browser omits its native `online` event and optional
14
+ 30-second expedited windows for foreground remote sends.
14
15
 
15
16
  ## Relay reads and lifecycle
16
17
 
@@ -633,9 +634,13 @@ tasks cannot deliver into a new channel generation. Mandatory persistence still
633
634
  owns its storage pause; incomplete history remains a pending recovery interval.
634
635
  Subscription setup is bounded separately from presence, so a slow announcement
635
636
  does not hold the other channels' readers. Sessions prioritize user persistence
636
- and publication before the next background presence signer call. Signing an outer
637
- envelope can overlap recovery-seed persistence, but no envelope is yielded for
638
- publication unless both succeed; only one outer is prepared at a time.
637
+ and publication before the next background presence signer call. `onPreparedSeed`
638
+ receives its existing router/jsonl fields plus `publication`, containing all
639
+ signed fragments, recipient/channel identities and recovery rows for that logical
640
+ event. The callback commits durable recovery before publication: a direct
641
+ messenger uses its seed store; the session uses its encrypted outbox journal and
642
+ archives indexed personal copies independently. No alternative legacy publisher
643
+ is retained. Only one logical event/chunk is prepared per session entry at a time.
639
644
 
640
645
  `private-channel.fetch` reports incomplete reads as an `AggregateError` with
641
646
  stable `code: 'PRIVATE_CHANNEL_FETCH_INCOMPLETE'`. Its message names relays,
@@ -1241,5 +1246,9 @@ Private messenger/session constructors accept optional `fallbackDelayMs` (`null`
1241
1246
  by default). Automatic NIP-65 sends can start configured fallback relays after
1242
1247
  the chosen delay while primary publications retain their deadlines. Explicit
1243
1248
  routes keep sequential fallback; every encrypted recipient must be covered.
1249
+ Session `enqueue` accepts optional monotonic `attemptStartedAt`; supplied values
1250
+ make preparation consume that delay. Retries/reloads start a fresh attempt and
1251
+ uploads budget each chunk independently. Monotonic clocks never enter storage or
1252
+ Nostr events. Omitted timestamps keep the publication-local delay.
1244
1253
  `RelayPool.sendEvent` accepts a consumer `signal` without closing shared sockets.
1245
1254
  See [fallback and cancellation](private-messenger/session/README.md#relay-fallback).
package/network/README.md CHANGED
@@ -30,3 +30,34 @@ Successful probes do not guarantee a particular relay or HTTP server is reachabl
30
30
  Handle request failures, cancellations, and timeouts separately. Browsers can
31
31
  suspend background tabs, so recovery notification has no wall-clock guarantee.
32
32
  Consumers should keep their own work queues but should not duplicate probe loops.
33
+
34
+
35
+ ## Expedited connectivity detection
36
+
37
+ `onOnline(handler, { strict = false, expediteForMs = 0, onDebug })` can request
38
+ an optional urgent window (clamped to 30,000 ms; finite, nonnegative durations).
39
+ The same realm/mode monitor coalesces subscriptions: urgent requests can advance
40
+ the next check, at least two seconds after the previous start. Failed checks
41
+ repeat at five seconds between starts, without overlapping slow checks. Native
42
+ online/focus/visibility wakeups remain available. Removing a subscription removes
43
+ its demand without stopping other listeners.
44
+
45
+ The urgent loop does not spend/reset the ordinary jittered backoff. At expiry it
46
+ returns to the ordinary deadline, or a fresh interval of the preserved retry step
47
+ if that deadline passed; it never catches up missed checks. For example, a normal
48
+ 60-second wait remains a 60-second step after an unsuccessful urgent window.
49
+ Connectivity returning after the window must await normal recovery or a new user
50
+ send/retry; automatic retries never extend urgency. `expediteForMs` is scheduling
51
+ advice, not evidence of Internet connectivity or a relay/ACK delivery deadline.
52
+
53
+ Private-message sessions remember a runtime-only 30-second deadline for new
54
+ remote intents and manual retry. Only work actually waiting for network subscribes
55
+ with the remaining time, sharing one subscription per session. Self-chat, restored
56
+ outbox entries, chunks and automatic retries never renew that deadline. Lock and
57
+ close discard urgency. A confirmed monitor result resumes the existing automatic
58
+ pause owner without another immediate probe; explicit pauses remain caller-owned.
59
+
60
+ Optional `onDebug` reports only probe reason, state, remaining window and duration.
61
+ Consumers wire it only in development; it is disabled by default. The monitor
62
+ retains no diagnostic history. Session diagnostics additionally correlate the
63
+ network wait and pause-release duration with the event ID.
package/network/index.js CHANGED
@@ -133,6 +133,8 @@ export function createConnectivityMonitor ({
133
133
  _setTimeout = globalThis.setTimeout,
134
134
  _clearTimeout = globalThis.clearTimeout,
135
135
  _random = Math.random,
136
+ _now = () => performance.now(),
137
+ onDebug,
136
138
  reportError = error => console.error('Online listener failed', error)
137
139
  } = {}) {
138
140
  if (check !== undefined && typeof check !== 'function') throw new ValidationError('INVALID_CONNECTIVITY_CHECK')
@@ -148,21 +150,49 @@ export function createConnectivityMonitor ({
148
150
  try { Promise.resolve(entry.handler()).catch(reportError) } catch (error) { reportError(error) }
149
151
  }
150
152
 
151
- function schedule (current) {
152
- if (session !== current || !listeners.size) return
153
- const delay = current.online ? 60000 : RETRY_DELAYS[Math.min(current.retry++, RETRY_DELAYS.length - 1)]
153
+ const urgencyEnd = () => Math.max(0, ...[...listeners].map(entry => entry.expediteUntil))
154
+ function arm (current) {
155
+ if (session !== current || current.pending || !listeners.size) return
156
+ if (current.timer != null) clearTimer(current.timer)
157
+ const now = _now()
158
+ const until = urgencyEnd()
159
+ const urgent = until > now && (!current.online || current.urgentDue != null)
160
+ // Urgent checks do not consume or reset the ordinary retry sequence.
161
+ if (!urgent && current.normalDue <= now) current.normalDue = now + current.normalDelay
162
+ let due = current.normalDue
163
+ if (urgent) due = Math.min(until, current.urgentDue ?? current.lastStartedAt + 5000)
154
164
  current.timer = setTimer(() => {
155
165
  current.timer = null
156
- return probe(current)
157
- }, Math.round(delay * (0.8 + _random() * 0.4)))
166
+ if (urgent && _now() >= urgencyEnd()) { arm(current); return }
167
+ return probe(current, urgent ? 'expedited' : 'backoff')
168
+ }, Math.max(0, due - now))
158
169
  current.timer?.unref?.()
159
170
  }
160
171
 
161
- async function probe (current) {
172
+ function schedule (current, expedited) {
173
+ if (session !== current || !listeners.size) return
174
+ if (!expedited || current.online) {
175
+ const delay = current.online ? 60000 : RETRY_DELAYS[Math.min(current.retry++, RETRY_DELAYS.length - 1)]
176
+ current.normalDelay = Math.round(delay * (0.8 + _random() * 0.4))
177
+ current.normalDue = _now() + current.normalDelay
178
+ }
179
+ current.urgentDue = null
180
+ arm(current)
181
+ }
182
+
183
+ async function probe (current, reason = 'wake') {
162
184
  if (session !== current || current.pending) return
163
185
  if (current.timer != null) clearTimer(current.timer)
164
186
  current.timer = null
165
187
  current.pending = true
188
+ current.lastStartedAt = _now()
189
+ const expedited = reason === 'expedited'
190
+ const startedAt = _now()
191
+ const report = values => {
192
+ const entry = { stage: 'connectivity-probe', reason, expediteRemainingMs: Math.max(0, urgencyEnd() - _now()), ...values }
193
+ for (const callback of [onDebug, ...[...listeners].map(listener => listener.onDebug)]) { try { callback?.(entry) } catch {} }
194
+ }
195
+ report({ state: 'started' })
166
196
  try {
167
197
  const online = await runCheck({ signal: current.controller.signal })
168
198
  if (session !== current) return
@@ -179,12 +209,13 @@ export function createConnectivityMonitor ({
179
209
  for (const entry of listeners) entry.delivered = false
180
210
  } finally {
181
211
  current.pending = false
182
- schedule(current)
212
+ report({ state: current.online ? 'online' : 'offline', elapsedMs: _now() - startedAt })
213
+ schedule(current, expedited)
183
214
  }
184
215
  }
185
216
 
186
217
  function start () {
187
- const current = { online: false, retry: 0, timer: null, pending: false, controller: new AbortController() }
218
+ const current = { online: false, retry: 0, timer: null, pending: false, lastStartedAt: -Infinity, normalDue: Infinity, normalDelay: 5000, urgentDue: null, controller: new AbortController() }
188
219
  session = current
189
220
  current.wake = () => { probe(current) }
190
221
  current.connectionChanged = () => {
@@ -213,18 +244,29 @@ export function createConnectivityMonitor ({
213
244
  }
214
245
 
215
246
  // Notify once on confirmed initial connectivity, then after each detected reconnection.
216
- function onOnline (handler) {
247
+ function onOnline (handler, { expediteForMs = 0, onDebug } = {}) {
217
248
  if (typeof handler !== 'function') throw new ValidationError('INVALID_ONLINE_HANDLER')
218
- const entry = { handler, delivered: false }
249
+ if (!Number.isFinite(expediteForMs) || expediteForMs < 0) throw new ValidationError('INVALID_EXPEDITE_FOR_MS')
250
+ if (onDebug !== undefined && typeof onDebug !== 'function') throw new ValidationError('INVALID_CONNECTIVITY_DEBUG')
251
+ const entry = { handler, onDebug, delivered: false, expediteUntil: _now() + Math.min(expediteForMs, 30000) }
219
252
  listeners.add(entry)
220
253
  if (!session) start()
221
254
  else {
222
255
  const current = session
223
- queueMicrotask(() => { probe(current) })
256
+ if (expediteForMs > 0 || urgencyEnd() > _now()) {
257
+ current.urgentDue = Math.max(_now(), current.lastStartedAt + 2000)
258
+ arm(current)
259
+ } else {
260
+ queueMicrotask(() => {
261
+ if (urgencyEnd() > _now()) arm(current)
262
+ else probe(current)
263
+ })
264
+ }
224
265
  }
225
266
  return () => {
226
267
  if (!listeners.delete(entry)) return
227
268
  if (!listeners.size) stop()
269
+ else arm(session)
228
270
  }
229
271
  }
230
272
 
@@ -234,12 +276,12 @@ export function createConnectivityMonitor ({
234
276
  const defaultMonitors = new Map()
235
277
 
236
278
  // Share probes, capped backoff and wake-up listeners per mode in this realm.
237
- export function onOnline (handler, { strict = false } = {}) {
279
+ export function onOnline (handler, { strict = false, expediteForMs = 0, onDebug } = {}) {
238
280
  const key = strict ? 'strict' : 'lenient'
239
281
  let monitor = defaultMonitors.get(key)
240
282
  if (!monitor) {
241
283
  monitor = createConnectivityMonitor({ strict })
242
284
  defaultMonitors.set(key, monitor)
243
285
  }
244
- return monitor.onOnline(handler)
286
+ return monitor.onOnline(handler, { expediteForMs, onDebug })
245
287
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.24",
3
+ "version": "0.11.26",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -143,6 +143,7 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
143
143
  channelPubkey,
144
144
  channelReaderPubkey,
145
145
  preparedRows,
146
+ innerEventId: getEventHash({ ...event, pubkey: event.pubkey || senderPubkey }),
146
147
  imkcPubkey,
147
148
  imkcProof
148
149
  }
@@ -152,69 +153,74 @@ async function prepareRoutedMessage ({ senderSigner, imkcSigner, privateChannelS
152
153
  }
153
154
  }
154
155
 
155
- async function * wrapPreparedEvents ({ privateChannelSigner, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context }) {
156
- const routerSeckey = generateSecretKey()
157
- const routerPubkey = getPublicKey(routerSeckey)
158
- const receiverPubkeyList = receiverPubkeys(receivers)
159
- const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
160
- const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
161
- const total = preparedChunkCount(context.preparedRows, rowIndexes)
162
- const persistSeeds = async () => {
163
- if (!onPreparedSeed) return
164
- const router = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
156
+ async function * wrapPreparedEvents ({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, expirationSeconds = EXPIRATION_SECONDS, context, preparedPublication }) {
157
+ let publication = preparedPublication
158
+ if (publication) {
159
+ const channelPubkey = await privateChannelSigner.getPublicKey()
160
+ const expected = receiverPubkeys(receivers).sort().join(',')
161
+ const innerEventId = getEventHash({ ...event, pubkey: event.pubkey || await senderSigner.getPublicKey() })
162
+ if (publication.innerEventId !== innerEventId || publication.channelPubkey !== channelPubkey || !Array.isArray(publication.receiverPubkeys) || publication.receiverPubkeys.slice().sort().join(',') !== expected ||
163
+ !Array.isArray(publication.events) || !publication.events.length || !Array.isArray(publication.seeds) ||
164
+ publication.events.some(event => !isValidEvent(event) || event.pubkey !== channelPubkey || event.kind !== PRIVATE_BROADCAST_KIND)) {
165
+ throw new ValidationError('INVALID_PREPARED_PUBLICATION')
166
+ }
167
+ if (publication.events.some(event => Number(event.tags.find(tag => tag[0] === 'expiration')?.[1]) <= nowSeconds())) throw new ValidationError('PREPARED_PUBLICATION_EXPIRED')
168
+ } else {
169
+ const routerSeckey = generateSecretKey()
170
+ const routerPubkey = getPublicKey(routerSeckey)
171
+ const receiverPubkeyList = receiverPubkeys(receivers)
172
+ const routerReceiverTag = receiverTag ?? (receiverPubkeyList.length === 1 ? receiverPubkeyList[0] : '')
173
+ const rowIndexes = preparedRowIndexesForReceivers(context.preparedRows, receivers)
174
+ const total = preparedChunkCount(context.preparedRows, rowIndexes)
175
+ const seedRouter = makeRouterEvent({ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey, imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, chunkIndex: 0, chunkTotal: 1, fileChunkIndex, content: '' })
165
176
  const payloadRow = readPreparedRow(context.preparedRows, 0)
166
- for (const rowIndex of rowIndexes) {
167
- await onPreparedSeed({ channelPubkey: context.channelPubkey, router, jsonl: `${payloadRow}\n${readPreparedRow(context.preparedRows, rowIndex)}\n` })
177
+ const seeds = rowIndexes.map(rowIndex => ({ channelPubkey: context.channelPubkey, router: seedRouter, jsonl: `${payloadRow}\n${readPreparedRow(context.preparedRows, rowIndex)}\n` }))
178
+ const events = []
179
+ let index = 0
180
+ for (const content of preparedChunks(context.preparedRows, rowIndexes)) {
181
+ const router = finalizeEvent(makeRouterEvent({
182
+ pubkey: routerPubkey, senderPubkey: context.senderPubkey, imkcPubkey: context.imkcPubkey,
183
+ imkcProof: context.imkcProof, receiverPubkey: routerReceiverTag, fileChunkIndex,
184
+ chunkIndex: index++, chunkTotal: total, content
185
+ }), routerSeckey)
186
+ const createdAt = nowSeconds()
187
+ const outer = await privateChannelSigner.signEvent({
188
+ kind: PRIVATE_BROADCAST_KIND, created_at: createdAt,
189
+ tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
190
+ content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
191
+ })
192
+ if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
193
+ events.push(outer)
168
194
  }
195
+ publication = { innerEventId: context.innerEventId, channelPubkey: context.channelPubkey, receiverPubkeys: receiverPubkeyList, events, seeds }
169
196
  }
170
- let index = 0
171
- for (const content of preparedChunks(context.preparedRows, rowIndexes)) {
172
- const router = finalizeEvent(makeRouterEvent({
173
- pubkey: routerPubkey,
174
- senderPubkey: context.senderPubkey,
175
- imkcPubkey: context.imkcPubkey,
176
- imkcProof: context.imkcProof,
177
- receiverPubkey: routerReceiverTag,
178
- fileChunkIndex,
179
- chunkIndex: index++,
180
- chunkTotal: total,
181
- content
182
- }), routerSeckey)
183
- const createdAt = nowSeconds()
184
- const prepareOuter = async () => privateChannelSigner.signEvent({
185
- kind: PRIVATE_BROADCAST_KIND,
186
- created_at: createdAt,
187
- tags: privateBroadcastTags({ deletionPubkey, createdAt, expirationSeconds }),
188
- content: await nip44v3EncryptText(privateChannelSigner, context.channelReaderPubkey, PRIVATE_BROADCAST_KIND, JSON.stringify(router))
189
- })
190
- // These independent steps may run together, but no envelope is yielded
191
- // until every seed is durable. Settle both before releasing prepared rows.
192
- const [persistence, prepared] = await Promise.allSettled([index === 1 ? persistSeeds() : undefined, prepareOuter()])
193
- if (persistence.status === 'rejected') throw persistence.reason
194
- if (prepared.status === 'rejected') throw prepared.reason
195
- const outer = prepared.value
196
- if (eventByteLength(outer) > MAX_EVENT_BYTES) throw new ValidationError('EVENT_TOO_LARGE')
197
- yield outer
197
+ // The callback commits recovery data before any bytes leave this publisher.
198
+ // A session commits its encrypted journal; a direct messenger commits seeds.
199
+ if (onPreparedSeed) {
200
+ for (const seed of publication.seeds) await onPreparedSeed({ ...seed, publication })
198
201
  }
202
+ yield * publication.events
199
203
  }
200
204
 
201
205
  // Streaming version of wrapEvent
202
- export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs }) {
206
+ export async function * wrapEvents ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, expirationSeconds = EXPIRATION_SECONDS, temporaryStorageArea, _getIykcProofs = getIykcProofs, preparedPublication }) {
203
207
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
204
- const context = await prepareRoutedMessage({
205
- senderSigner,
206
- imkcSigner,
207
- privateChannelSigner,
208
- privateChannelReaderPubkey,
209
- receivers,
210
- event,
211
- temporaryStorageArea,
212
- _getIykcProofs
213
- })
208
+ const context = preparedPublication
209
+ ? null
210
+ : await prepareRoutedMessage({
211
+ senderSigner,
212
+ imkcSigner,
213
+ privateChannelSigner,
214
+ privateChannelReaderPubkey,
215
+ receivers,
216
+ event,
217
+ temporaryStorageArea,
218
+ _getIykcProofs
219
+ })
214
220
  try {
215
- yield * wrapPreparedEvents({ privateChannelSigner, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })
221
+ yield * wrapPreparedEvents({ privateChannelSigner, senderSigner, event, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context, preparedPublication })
216
222
  } finally {
217
- cleanupEnvelopeRows(context.preparedRows)
223
+ if (context) cleanupEnvelopeRows(context.preparedRows)
218
224
  }
219
225
  }
220
226
 
@@ -552,35 +558,37 @@ function withRecoveryRelays (relays, recoveryRelays) {
552
558
  return uniq([...(relays || []), ...(recoveryRelays || [])])
553
559
  }
554
560
 
555
- export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays }) {
561
+ export async function publish ({ senderSigner, imkcSigner, privateChannelSigner = senderSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, _getIykcProofs = getIykcProofs, _publish = sendToRelays, preparedPublication }) {
556
562
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
557
563
  const results = []
558
564
  // Relays are grouped only when they have the exact same recipient pubkey set
559
565
  const groups = groupedRelayReceivers({ relayToReceivers, receivers })
560
566
  if (groups) {
561
- const context = await prepareRoutedMessage({
562
- senderSigner,
563
- imkcSigner,
564
- privateChannelSigner,
565
- privateChannelReaderPubkey,
566
- receivers,
567
- event,
568
- temporaryStorageArea,
569
- _getIykcProofs
570
- })
567
+ const context = preparedPublication
568
+ ? null
569
+ : await prepareRoutedMessage({
570
+ senderSigner,
571
+ imkcSigner,
572
+ privateChannelSigner,
573
+ privateChannelReaderPubkey,
574
+ receivers,
575
+ event,
576
+ temporaryStorageArea,
577
+ _getIykcProofs
578
+ })
571
579
  try {
572
580
  for (const group of groups) {
573
- for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, receivers: group.receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context })) {
581
+ for await (const wrappedEvent of wrapPreparedEvents({ privateChannelSigner, senderSigner, event, receivers: group.receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, expirationSeconds, context, preparedPublication })) {
574
582
  results.push(await _publish(wrappedEvent, withRecoveryRelays(group.relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(group.receivers), primaryRelays: group.relays }))
575
583
  }
576
584
  }
577
585
  } finally {
578
- cleanupEnvelopeRows(context.preparedRows)
586
+ if (context) cleanupEnvelopeRows(context.preparedRows)
579
587
  }
580
588
  return results
581
589
  }
582
590
 
583
- for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs })) {
591
+ for await (const wrappedEvent of wrapEvents({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, fileChunkIndex, onPreparedSeed, deletionPubkey: normalizedDeletionPubkey, event, expirationSeconds, temporaryStorageArea, _getIykcProofs, preparedPublication })) {
584
592
  results.push(await _publish(wrappedEvent, withRecoveryRelays(relays, recoveryRelays), { receiverPubkeys: receiverPubkeys(receivers), primaryRelays: relays }))
585
593
  }
586
594
  return results
@@ -1,7 +1,7 @@
1
1
  import { normalizeRelayUrl } from '../url/index.js'
2
2
  import { isValidEvent } from '../event/index.js'
3
3
  import { ValidationError } from '../error/index.js'
4
- import { generateKeypair } from '../key/index.js'
4
+ import { generateKeypair, keypairFromSeckey } from '../key/index.js'
5
5
  import * as privateChannel from '../private-channel/index.js'
6
6
  import { normalizeRumor, deliveryInfo } from '../private-channel/helpers/rumor.js'
7
7
 
@@ -41,11 +41,19 @@ function normalizeDeletionPubkey (deletionPubkey) {
41
41
  return deletionPubkey.toLowerCase()
42
42
  }
43
43
 
44
- function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeletionCapability = true }) {
44
+ function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeletionCapability = true, preparedPublication }) {
45
45
  if (deletionSeckey !== undefined) throw new ValidationError('DELETION_SECKEY_NOT_ACCEPTED')
46
46
  if (typeof autoDeletionCapability !== 'boolean') throw new ValidationError('AUTO_DELETION_CAPABILITY_BOOLEAN_REQUIRED')
47
47
 
48
48
  const normalizedDeletionPubkey = normalizeDeletionPubkey(deletionPubkey)
49
+ if (preparedPublication) {
50
+ const capability = preparedPublication.deletionCapability
51
+ if (!capability || (capability.deletionPubkey !== undefined && normalizeDeletionPubkey(capability.deletionPubkey) !== capability.deletionPubkey) ||
52
+ (normalizedDeletionPubkey && normalizedDeletionPubkey !== capability.deletionPubkey) ||
53
+ (capability.deletionSeckey !== undefined && keypairFromSeckey(capability.deletionSeckey).pubkey !== capability.deletionPubkey) ||
54
+ preparedPublication.events?.some(event => event.tags.find(tag => tag[0] === 's')?.[1] !== capability.deletionPubkey)) throw new ValidationError('INVALID_PREPARED_PUBLICATION')
55
+ return { ...capability }
56
+ }
49
57
  if (normalizedDeletionPubkey) return { deletionPubkey: normalizedDeletionPubkey }
50
58
  if (!autoDeletionCapability) return {}
51
59
 
@@ -53,6 +61,14 @@ function resolveDeletionCapability ({ deletionPubkey, deletionSeckey, autoDeleti
53
61
  return { deletionPubkey: pubkey, deletionSeckey: seckey }
54
62
  }
55
63
 
64
+ function preparedSeedHandler (onPreparedSeed, deletion) {
65
+ if (!onPreparedSeed) return undefined
66
+ return seed => {
67
+ seed.publication.deletionCapability = deletion
68
+ return onPreparedSeed(seed)
69
+ }
70
+ }
71
+
56
72
  function withDelivery (result, reports, deletionSeckey) {
57
73
  const delivery = { reports }
58
74
  if (deletionSeckey !== undefined) delivery.deletionSeckey = deletionSeckey
@@ -652,10 +668,11 @@ async function sendPrivateMessage ({
652
668
  _getIykcProofs,
653
669
  fileChunkIndex,
654
670
  onPreparedSeed,
671
+ preparedPublication,
655
672
  _publish = privateChannel.publish
656
673
  }) {
657
674
  if (!privateChannelSigner?.getPublicKey) throw new ValidationError('PRIVATE_CHANNEL_WRITER_REQUIRED')
658
- return _publish({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs })
675
+ return _publish({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag, deletionPubkey, event, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, preparedPublication, _getIykcProofs })
659
676
  }
660
677
 
661
678
  async function sendNymMessage ({
@@ -860,13 +877,14 @@ export async function broadcastRumor ({
860
877
  autoDeletionCapability = true,
861
878
  fileChunkIndex,
862
879
  onPreparedSeed,
880
+ preparedPublication,
863
881
  _publish = privateChannel.publish
864
882
  }) {
865
883
  const receivers = uniq(receiverPubkeys)
866
884
  if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
867
885
  const { event, wireEvent } = await makeOutgoingRumor({ senderSigner, rumor })
868
- const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
869
- const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
886
+ const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability, preparedPublication })
887
+ const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed: preparedSeedHandler(onPreparedSeed, deletion), preparedPublication, _getIykcProofs, _publish })
870
888
  return withDelivery({ rumor: event }, reports, deletion.deletionSeckey)
871
889
  }
872
890
 
@@ -888,13 +906,14 @@ export async function broadcastEvent ({
888
906
  autoDeletionCapability = true,
889
907
  fileChunkIndex,
890
908
  onPreparedSeed,
909
+ preparedPublication,
891
910
  _publish = privateChannel.publish
892
911
  }) {
893
912
  const receivers = uniq(receiverPubkeys)
894
913
  if (!receivers.length) throw new ValidationError('NO_RECEIVERS')
895
914
  const wireEvent = assertValidSignedEvent({ ...event, tags: cloneTags(event?.tags) })
896
- const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability })
897
- const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed, _getIykcProofs, _publish })
915
+ const deletion = resolveDeletionCapability({ deletionPubkey, deletionSeckey, autoDeletionCapability, preparedPublication })
916
+ const reports = await sendPrivateMessage({ senderSigner, imkcSigner, privateChannelSigner, privateChannelReaderPubkey, receivers, receiverTag: '', deletionPubkey: deletion.deletionPubkey, event: wireEvent, relays, relayToReceivers, recoveryRelays, expirationSeconds, temporaryStorageArea, fileChunkIndex, onPreparedSeed: preparedSeedHandler(onPreparedSeed, deletion), preparedPublication, _getIykcProofs, _publish })
898
917
  return withDelivery({ event: wireEvent }, reports, deletion.deletionSeckey)
899
918
  }
900
919
 
@@ -144,14 +144,14 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
144
144
  if (decoded.root !== file.root || (index !== undefined && decoded.index !== index) || (file.size !== undefined && (decoded.total !== Math.ceil(file.size / IRFS_CHUNK_BYTES) || (decoded.index === decoded.total - 1 && decoded.contentBytes.length !== file.size - decoded.index * IRFS_CHUNK_BYTES)))) throw new ValidationError('FILE_CHUNK_DESCRIPTOR_MISMATCH')
145
145
  return decoded
146
146
  }
147
- async function routing (file, receiverPubkey, signal) {
147
+ async function routing (file, receiverPubkey, signal, attemptStartedAt, onDebug) {
148
148
  const parent = messenger.requireWritableChannel(file.controlChannelPubkey)
149
- return messenger.resolveSendRouting({ channel: parent, receiverPubkeys: [receiverPubkey], signal })
149
+ return messenger.resolveSendRouting({ channel: parent, receiverPubkeys: [receiverPubkey], signal, attemptStartedAt, onDebug })
150
150
  }
151
151
  function sendOptions (file, routes) {
152
152
  return { senderSigner: messenger.userSigner, imkcSigner: messenger.contentKeySigner, privateChannelSigner: file.signer, ...routes, temporaryStorageArea: messenger.temporaryStorageArea, expirationSeconds: messenger.eventExpirationSecondsFor(file.controlChannelPubkey), _getIykcProofs: messenger.contentKeyLookup() }
153
153
  }
154
- async function publishChunk (descriptor, event, { signal } = {}) {
154
+ async function publishChunk (descriptor, event, { signal, attemptStartedAt, onPreparedSeed, preparedPublication, onDebug } = {}) {
155
155
  const file = await register(descriptor)
156
156
  usable(file)
157
157
  const decoded = checked(file, event)
@@ -162,7 +162,7 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
162
162
  }
163
163
  let result
164
164
  try {
165
- const options = { ...sendOptions(file, await routing(file, file.peerPubkey, signal)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, ...(messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
165
+ const options = { ...sendOptions(file, await routing(file, file.peerPubkey, signal, attemptStartedAt, onDebug)), receiverPubkeys: [file.peerPubkey], fileChunkIndex: decoded.index, preparedPublication, ...(onPreparedSeed ? { onPreparedSeed } : messenger.channels.get(file.controlChannelPubkey)?.mode === 'watchtower' ? { onPreparedSeed: seed => saveSeed(file, seed) } : {}) }
166
166
  result = event.sig ? await _messages.broadcastEvent({ ...options, event }) : await _messages.broadcastRumor({ ...options, rumor: event })
167
167
  await assertMessagePublished(result, result?.rumor || result?.event || { ...event, pubkey: event.pubkey || messenger.userPubkey })
168
168
  } catch (error) { await uploadLeases.get(file.root)?.(); uploadLeases.delete(file.root); throw error }
@@ -463,5 +463,5 @@ export function createPrivateFileTransfer ({ messenger, resolveChannel, storage,
463
463
  const timer = setInterval(() => { prune().catch(onError) }, 60000)
464
464
  timer.unref?.()
465
465
  messenger.extensions.add(extension)
466
- return { register, authorizeSeeding, publishChunk, download, readChunk: (root, index) => storage.read(root, index), stream: (descriptor, options = {}) => storage.stream ? storage.stream(descriptor, options) : chunkStream({ read: storage.read.bind(storage), ...descriptor, ...options }), close: extension.close, cancel (control, root) { const job = jobs.get(keyFor(control, root)); if (job) { job.pauseRequested = false; cancelJob(job) } }, observe (listener) { listeners.add(listener); for (const job of jobs.values()) emit(job); return () => listeners.delete(listener) } }
466
+ return { register, authorizeSeeding, publishChunk, archiveSeed: async (descriptor, seed) => saveSeed(await register(descriptor), seed), download, readChunk: (root, index) => storage.read(root, index), stream: (descriptor, options = {}) => storage.stream ? storage.stream(descriptor, options) : chunkStream({ read: storage.read.bind(storage), ...descriptor, ...options }), close: extension.close, cancel (control, root) { const job = jobs.get(keyFor(control, root)); if (job) { job.pauseRequested = false; cancelJob(job) } }, observe (listener) { listeners.add(listener); for (const job of jobs.values()) emit(job); return () => listeners.delete(listener) } }
467
467
  }
@@ -2,7 +2,7 @@ import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
2
2
 
3
3
  // Each invocation owns one already signed outer event and its encrypted subset.
4
4
  // Lanes share coverage and the tried set; only started reports outlive the race.
5
- export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, delay, now, setTimer, clearTimer }) {
5
+ export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, delay, attemptStartedAt, monotonicNow = () => performance.now(), now, setTimer, clearTimer }) {
6
6
  const pending = new Set(receivers)
7
7
  const summaries = []
8
8
  const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
@@ -120,7 +120,8 @@ export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tr
120
120
  }
121
121
  if (transportSignal?.aborted || !isCurrent()) { finish(false); return outcome.promise }
122
122
  transportSignal?.addEventListener('abort', interrupted, { once: true })
123
- const deadline = now() + delay
123
+ const remaining = attemptStartedAt === undefined ? delay : Math.max(0, delay - Math.max(0, monotonicNow() - attemptStartedAt))
124
+ const deadline = now() + remaining
124
125
  const failed = reason => {
125
126
  if (done) return
126
127
  done = true; clearTimer(timer); timer = null
@@ -33,11 +33,14 @@ export function createPauseRecovery ({ attempt, online, onOnline, retryable, onE
33
33
  const run = () => {
34
34
  if (!active() || job.work) return job.work
35
35
  const work = (async () => {
36
- if (online && !await online({ signal: job.controller.signal })) {
36
+ const confirmedOnline = job.confirmedOnline
37
+ job.confirmedOnline = false
38
+ if (online && !confirmedOnline && !await online({ signal: job.controller.signal })) {
37
39
  if (!active()) return
38
40
  job.stopOnline = onOnline(() => {
39
41
  if (!active()) return
40
42
  job.stopOnline?.(); job.stopOnline = null
43
+ job.confirmedOnline = true
41
44
  schedule(0)
42
45
  })
43
46
  return
@@ -32,10 +32,11 @@ function routeGroups (routes) {
32
32
  return [...groups.values()]
33
33
  }
34
34
 
35
- export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
35
+ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, attemptStartedAt, monotonicNow = () => performance.now(), now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
36
36
  // Keep discovery's defaults when NIP-65 is unavailable. Explicit maps/lists
37
37
  // retain their fanout; configured fallbacks are a separate last-resort stage.
38
38
  fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
39
+ if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
39
40
  const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
40
41
  const explicit = Boolean(primaryRelays || primaryRelayToReceivers)
41
42
  const primaryRoutes = primaryRelayToReceivers || (primaryRelays
@@ -98,7 +99,7 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
98
99
  event, receivers, first: first.length ? { receivers, relays: first } : null, mirrors, tried,
99
100
  nextPrimary: pending => pick(pending, false), nextFallback: pending => pick(pending, true),
100
101
  sendEvent, isOnline, isCurrent, signal, pauseSignal, remember,
101
- delay: fallbackDelayMs, now, setTimer, clearTimer
102
+ delay: fallbackDelayMs, attemptStartedAt, monotonicNow, now, setTimer, clearTimer
102
103
  })
103
104
  if (!result.success && current()) exclusions.clear()
104
105
  return result