libp2r2p 0.11.25 → 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
 
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.25",
3
+ "version": "0.11.26",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -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
@@ -45,7 +45,7 @@ function messengerStatusSnapshot (value) {
45
45
  return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
46
46
  }
47
47
 
48
- export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, pendingDecryptionReady = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, onDebug, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
48
+ export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, pendingDecryptionReady = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, onDebug, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, _now = () => performance.now(), openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
49
49
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
50
50
  fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
51
51
  if (typeof pendingDecryptionReady !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
@@ -67,6 +67,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
67
67
  const entries = new Map()
68
68
  const cancelled = new Set()
69
69
  const attemptStarts = new Map()
70
+ const networkUrgency = new Map()
70
71
  const sendControllers = new Map()
71
72
  const localReady = new Set()
72
73
  const localIdleWaiters = new Set()
@@ -79,6 +80,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
79
80
  const offlineSends = new Set()
80
81
  const pausedSends = new Set()
81
82
  let messengerStatus
83
+ let networkPauseStartedAt
84
+ let networkConfirmed = false
82
85
  let messengerBinding
83
86
  let storageBlocked = false
84
87
  const inboxRecovery = createPauseRecovery({
@@ -89,22 +92,31 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
89
92
  let inboxError
90
93
  const stateChanged = state => {
91
94
  if (closed) return
95
+ const wasNetworkPaused = messengerStatus?.pauseReasons.includes('network')
92
96
  messengerStatus = messengerStatusSnapshot(state)
97
+ if (!wasNetworkPaused && state.pauseReasons.includes('network')) { networkPauseStartedAt = _now(); networkConfirmed = false }
98
+ if (wasNetworkPaused && !state.pauseReasons.includes('network')) {
99
+ for (const entry of entries.values()) if (entry.peer !== owner && entry.status !== 'saved') debug(entry, 'network-pause-released', undefined, { elapsedMs: _now() - networkPauseStartedAt })
100
+ networkPauseStartedAt = undefined; networkConfirmed = false
101
+ }
93
102
  if (!available || state.closed) return
94
103
  if (state.paused) {
95
104
  for (const entry of entries.values()) {
96
105
  if (entry.peer !== owner && entry.failed && entry.status === 'pending') pausedSends.add(entry.id)
97
106
  }
98
107
  if (![...entries.values()].some(entry => pendingRetryable(entry) && !pausedSends.has(entry.id))) clearSendRetry()
108
+ watchOfflineSends()
99
109
  return
100
110
  }
101
111
  clearSendRetry()
102
112
  for (const id of pausedSends) { const entry = entries.get(id); if (entry) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) } }
103
113
  pausedSends.clear()
114
+ watchOfflineSends()
104
115
  // The callback can run inside construction/resume, before configure settles.
105
116
  queueMicrotask(() => pump())
106
117
  }
107
118
  let stopOnline
119
+ let requestedUrgency = 0
108
120
  let sendRetryTimer
109
121
  let sendRetryDelay = SEND_RETRY_MIN_MS
110
122
  let messenger
@@ -152,6 +164,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
152
164
  }
153
165
  },
154
166
  changed: entry => {
167
+ watchOfflineSends()
155
168
  if (!entry.prepared && (entry.status === 'saved' || entry.cancelled)) cleanupEntry(entry)
156
169
  else if (!closed && available) pumpRemote()
157
170
  }
@@ -559,7 +572,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
559
572
  emit()
560
573
  } finally { sendControllers.delete(entry.id); releaseForeground() }
561
574
  }
562
- function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined }
575
+ function stopOnlineRetry () { stopOnline?.(); stopOnline = undefined; requestedUrgency = 0 }
563
576
  // Transient availability failures without an active messenger pause retain
564
577
  // bounded retries. Observed pauses wait exclusively for their release.
565
578
  function clearSendRetry () {
@@ -591,21 +604,47 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
591
604
  sendRetryTimer.unref?.()
592
605
  }
593
606
  function watchOfflineSends () {
594
- if (closed || !available || !offlineSends.size) { stopOnlineRetry(); return }
595
- if (stopOnline) return
596
- // A fresh listener receives confirmed connectivity even when a brief outage
597
- // occurred between the app's long-lived monitor checks.
607
+ const waiting = [...entries.values()].filter(entry => entry.peer !== owner && peers.has(entry.peer) && entry.status !== 'saved' && !entry.cancelled && !cancelled.has(entry.id) && !entry.prepared?.accepted && (!entry.failed || pendingRetryable(entry)) &&
608
+ (offlineSends.has(entry.id) || (!networkConfirmed && messengerStatus?.pauseReasons.includes('network'))))
609
+ for (const id of networkUrgency.keys()) {
610
+ const entry = entries.get(id)
611
+ if (!entry || entry.status === 'saved' || entry.cancelled || cancelled.has(id)) networkUrgency.delete(id)
612
+ }
613
+ if (closed || !available || !waiting.length) { stopOnlineRetry(); return }
614
+ const deadline = Math.max(0, ...waiting.map(entry => networkUrgency.get(entry.id) ?? 0))
615
+ if (stopOnline && deadline === requestedUrgency) return
616
+ const previous = stopOnline
617
+ requestedUrgency = deadline
618
+ const startedAt = _now()
619
+ // Replacing a subscription retains the shared loop and its backoff. This is
620
+ // only done when a new user action extends the window, never per chunk/retry.
598
621
  stopOnline = _onOnline(() => {
599
622
  stopOnlineRetry()
600
623
  if (closed || !available) return
624
+ networkConfirmed = true
625
+ for (const entry of waiting) debug(entry, 'network-confirmed', undefined, { elapsedMs: _now() - startedAt })
601
626
  for (const id of offlineSends) {
602
627
  const entry = entries.get(id)
603
628
  if (entry?.retryable) { entry.failed = false; attemptStarts.set(entry.id, performance.now()) }
604
629
  }
605
630
  offlineSends.clear()
631
+ // The messenger's automatic recovery owns its network pause; the same
632
+ // monitor also notifies that recovery job. Never resume explicit pauses.
606
633
  return schedule()
634
+ }, {
635
+ expediteForMs: Math.max(0, deadline - _now()),
636
+ ...(onDebug
637
+ ? {
638
+ onDebug: measure => {
639
+ const entry = waiting[0]
640
+ debug(entry, measure.stage, undefined, measure)
641
+ }
642
+ }
643
+ : {})
607
644
  })
645
+ previous?.()
608
646
  }
647
+
609
648
  async function pumpLocal () {
610
649
  if (!available || closed || !storage) return
611
650
  if (localSaving) { relocal = true; return }
@@ -738,6 +777,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
738
777
  const next = new Set([...values].filter(peer => peer !== owner))
739
778
  if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
740
779
  peers.clear(); for (const peer of next) peers.add(peer)
780
+ watchOfflineSends()
741
781
  preparation.reconcile(peers)
742
782
  if (pendingDecryptionPeer && !peers.has(pendingDecryptionPeer)) {
743
783
  pendingDecryptionPeer = null
@@ -757,7 +797,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
757
797
  const changed = available !== (value === true)
758
798
  available = value === true
759
799
  if (!changed && !available) return
760
- stopOnlineRetry(); offlineSends.clear()
800
+ if (changed || !available) { stopOnlineRetry(); offlineSends.clear() }
801
+ if (!available) { networkUrgency.clear(); networkConfirmed = false }
761
802
  if (available) resetSendRetry()
762
803
  else clearSendRetry()
763
804
  if (changed) lifecycle++
@@ -778,6 +819,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
778
819
  async enqueue ({ peer, event, context = [], requiredFiles = [], deletion = false, attemptStartedAt }) {
779
820
  if (closed || !available || (peer !== owner && !peers.has(peer))) throw new Error('CHAT_UNAVAILABLE')
780
821
  if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
822
+ const urgencyUntil = _now() + 30000
823
+ const urgencyLifecycle = lifecycle
781
824
  const releaseForeground = foreground.enter()
782
825
  enqueuing++
783
826
  try {
@@ -797,17 +840,26 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
797
840
  // An accepted durable intent survives shutdown/locking for replay. Only
798
841
  // explicit cancellation removes it; no late callback or work is admitted.
799
842
  if (closed) return id
800
- entries.set(id, entry); emit(); pump()
843
+ entries.set(id, entry)
844
+ if (peer !== owner && available && lifecycle === urgencyLifecycle) networkUrgency.set(id, urgencyUntil)
845
+ watchOfflineSends(); emit(); pump()
801
846
  if (available && peer !== owner) preparePeer(peer).catch(() => {})
802
847
  return id
803
848
  } finally { releaseForeground(); enqueuing--; settled() }
804
849
  },
805
850
  async retry (id) {
806
- offlineSends.delete(id); pausedSends.delete(id); watchOfflineSends()
807
851
  if (storageBlocked) inboxRecovery.start({ immediate: true })
808
852
  await ready()
809
853
  const entry = entries.get(id)
810
- if (entry) { attemptStarts.set(id, performance.now()); journal.retry(); entry.failed = false; resetSendRetry(); if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {}); return pump() }
854
+ if (!entry) return
855
+ if (entry.peer !== owner && entry.status !== 'saved' && available) networkUrgency.set(id, _now() + 30000)
856
+ // Renew while the old wait is still subscribed, preserving the monitor's
857
+ // generation/backoff even if this session is its only consumer.
858
+ watchOfflineSends()
859
+ offlineSends.delete(id); pausedSends.delete(id)
860
+ attemptStarts.set(id, performance.now()); journal.retry(); entry.failed = false; resetSendRetry()
861
+ if (entry.peer !== owner) preparePeer(entry.peer).catch(() => {})
862
+ return pump()
811
863
  },
812
864
  async cancel (id) {
813
865
  cancelled.add(id)
@@ -821,7 +873,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
821
873
  close () {
822
874
  return (closeWork ??= (async () => {
823
875
  closed = true; available = false; lifecycle++
824
- stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
876
+ stopOnlineRetry(); offlineSends.clear(); networkUrgency.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
825
877
  clearSendRetry(); inboxRecovery.stop(); preparation.close(); foreground.close(); pausedSends.clear()
826
878
  for (const controller of sendControllers.values()) controller.abort()
827
879
  await Promise.allSettled([configuring, initialized, messengerWork, fileSetupWork])