libp2r2p 0.10.10 → 0.10.11

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
@@ -8,6 +8,10 @@ the transport and discovery surface. The package was born to distribute the
8
8
  private messenger reference implementation, and it also carries a few Nostr
9
9
  power-ups used by that messenger.
10
10
 
11
+ For remote-work scheduling, see [`libp2r2p/network`](network/README.md):
12
+ `isOnline` probes connectivity and `onOnline` shares recovery monitoring,
13
+ including retries when the browser omits its native `online` event.
14
+
11
15
  ## Private Messenger
12
16
 
13
17
  The main API is `createPrivateMessenger` from `libp2r2p/private-messenger`.
@@ -0,0 +1,20 @@
1
+ # Connectivity
2
+
3
+ `isOnline({ signal } = {})` returns a boolean after probing public connectivity
4
+ endpoints, or immediately returns `false` when the browser reports offline.
5
+ Concurrent calls without a signal share one check. A supplied abort signal
6
+ cancels the caller's independent check. Each endpoint has a five-second timeout.
7
+
8
+ `onOnline(handler)` returns an idempotent unsubscribe function. It notifies
9
+ asynchronously after confirmed initial connectivity and each detected recovery.
10
+ The native `online` event only triggers a check; it is not sufficient evidence.
11
+ Listeners share a monitor with retries at 5, 15, 30, then 60 seconds (20% jitter),
12
+ checks on focus/visibility/network changes, and a 60-second interval while online.
13
+ Unsubscribing the last listener removes timers/listeners and aborts its probe.
14
+ Handler failures are isolated and logged. `createConnectivityMonitor(options)`
15
+ creates an independent monitor with an optional custom `check({ signal })`.
16
+
17
+ Successful probes do not guarantee a particular relay or HTTP server is reachable.
18
+ Handle request failures, cancellations, and timeouts separately. Browsers can
19
+ suspend background tabs, so recovery notification has no wall-clock guarantee.
20
+ Consumers should keep their own work queues but should not duplicate probe loops.
package/network/index.js CHANGED
@@ -1,25 +1,31 @@
1
- export async function isOnline () {
2
- if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') {
3
- if (!navigator.onLine) return false
4
- }
5
- return hasInternetConnectivity()
6
- }
1
+ import { ValidationError } from '../error/index.js'
7
2
 
3
+ const RETRY_DELAYS = [5000, 15000, 30000, 60000]
8
4
  const CONNECTIVITY_PROBE_URLS = [
9
5
  { url: 'https://www.gstatic.com/generate_204' },
10
6
  { url: 'https://connectivitycheck.gstatic.com/generate_204' },
11
7
  { url: 'https://captive.apple.com/hotspot-detect.html' },
12
8
  { method: 'GET', url: 'https://connectivity-check.ubuntu.com' }
13
9
  ]
10
+ let sharedCheck
11
+
12
+ // Treat the browser's offline flag as a fast failure; confirm online status with a probe.
13
+ export async function isOnline ({ signal } = {}) {
14
+ if (signal?.aborted) throw signal.reason
15
+ if (globalThis.navigator?.onLine === false) return false
16
+ if (signal) return hasInternetConnectivity(signal)
17
+ sharedCheck ??= hasInternetConnectivity().finally(() => { sharedCheck = null })
18
+ return sharedCheck
19
+ }
14
20
 
15
- async function hasInternetConnectivity () {
16
- const candidates = shuffle(CONNECTIVITY_PROBE_URLS)
17
- for (const candidate of candidates) {
21
+ async function hasInternetConnectivity (signal) {
22
+ for (const candidate of shuffle(CONNECTIVITY_PROBE_URLS)) {
23
+ if (signal?.aborted) throw signal.reason
18
24
  try {
19
- await ping(candidate.url, { method: candidate.method })
25
+ await ping(candidate.url, { method: candidate.method, signal })
20
26
  return true
21
- } catch (err) {
22
- console.warn('connectivity probe failed', candidate.url, err?.message ?? err)
27
+ } catch {
28
+ if (signal?.aborted) throw signal.reason
23
29
  }
24
30
  }
25
31
  return false
@@ -34,35 +40,143 @@ function shuffle (list) {
34
40
  return copy
35
41
  }
36
42
 
37
- async function ping (url, { method = 'HEAD', timeout = 5000 } = {}) {
38
- const abortController = typeof AbortController === 'function' ? new AbortController() : null
39
- let timerId = null
40
-
41
- const fetchPromise = fetch(url, {
42
- method,
43
- mode: 'no-cors',
44
- cache: 'no-store',
45
- redirect: 'follow',
46
- signal: abortController?.signal
47
- })
48
-
49
- const completionPromise = fetchPromise.finally(() => {
50
- if (timerId != null) clearTimeout(timerId)
51
- })
52
-
53
- const timeoutPromise = new Promise((_resolve, reject) => {
54
- timerId = setTimeout(() => {
55
- if (abortController) abortController.abort()
43
+ // Bound each probe and release its timer and abort listener on every exit path.
44
+ async function ping (url, { method = 'HEAD', timeout = 5000, signal } = {}) {
45
+ const controller = new AbortController()
46
+ let timer
47
+ let onAbort
48
+ const stopped = new Promise((_resolve, reject) => {
49
+ onAbort = () => {
50
+ controller.abort(signal.reason)
51
+ reject(signal.reason)
52
+ }
53
+ signal?.addEventListener('abort', onAbort, { once: true })
54
+ timer = setTimeout(() => {
55
+ controller.abort()
56
56
  reject(new Error('PING_TIMEOUT'))
57
57
  }, timeout)
58
+ if (signal?.aborted) onAbort()
58
59
  })
60
+ try {
61
+ await Promise.race([
62
+ fetch(url, { method, mode: 'no-cors', cache: 'no-store', redirect: 'follow', signal: controller.signal }),
63
+ stopped
64
+ ])
65
+ } finally {
66
+ clearTimeout(timer)
67
+ signal?.removeEventListener('abort', onAbort)
68
+ }
69
+ }
59
70
 
60
- await Promise.race([completionPromise, timeoutPromise])
61
- return true
71
+ // A monitor owns one probe loop, regardless of how many consumers subscribe.
72
+ export function createConnectivityMonitor ({
73
+ check = isOnline,
74
+ eventTarget = globalThis.window,
75
+ document = globalThis.document,
76
+ _setTimeout = globalThis.setTimeout,
77
+ _clearTimeout = globalThis.clearTimeout,
78
+ _random = Math.random,
79
+ reportError = error => console.error('Online listener failed', error)
80
+ } = {}) {
81
+ if (typeof check !== 'function') throw new ValidationError('INVALID_CONNECTIVITY_CHECK')
82
+ const listeners = new Set()
83
+ const setTimer = (...args) => Reflect.apply(_setTimeout, globalThis, args)
84
+ const clearTimer = (...args) => Reflect.apply(_clearTimeout, globalThis, args)
85
+ let session
86
+
87
+ function deliver (entry, current) {
88
+ if (entry.delivered || !listeners.has(entry) || session !== current) return
89
+ entry.delivered = true
90
+ try { Promise.resolve(entry.handler()).catch(reportError) } catch (error) { reportError(error) }
91
+ }
92
+
93
+ function schedule (current) {
94
+ if (session !== current || !listeners.size) return
95
+ const delay = current.online ? 60000 : RETRY_DELAYS[Math.min(current.retry++, RETRY_DELAYS.length - 1)]
96
+ current.timer = setTimer(() => {
97
+ current.timer = null
98
+ return probe(current)
99
+ }, Math.round(delay * (0.8 + _random() * 0.4)))
100
+ current.timer?.unref?.()
101
+ }
102
+
103
+ async function probe (current) {
104
+ if (session !== current || current.pending) return
105
+ if (current.timer != null) clearTimer(current.timer)
106
+ current.timer = null
107
+ current.pending = true
108
+ try {
109
+ const online = await check({ signal: current.controller.signal })
110
+ if (session !== current) return
111
+ current.online = online === true && globalThis.navigator?.onLine !== false
112
+ if (current.online) {
113
+ current.retry = 0
114
+ for (const entry of [...listeners]) deliver(entry, current)
115
+ } else {
116
+ for (const entry of listeners) entry.delivered = false
117
+ }
118
+ } catch {
119
+ if (session !== current) return
120
+ current.online = false
121
+ for (const entry of listeners) entry.delivered = false
122
+ } finally {
123
+ current.pending = false
124
+ schedule(current)
125
+ }
126
+ }
127
+
128
+ function start () {
129
+ const current = { online: false, retry: 0, timer: null, pending: false, controller: new AbortController() }
130
+ session = current
131
+ current.wake = () => { probe(current) }
132
+ current.connectionChanged = () => {
133
+ current.online = false
134
+ current.retry = 0
135
+ for (const entry of listeners) entry.delivered = false
136
+ current.wake()
137
+ }
138
+ current.visible = () => { if (document?.visibilityState !== 'hidden') current.wake() }
139
+ eventTarget?.addEventListener('online', current.connectionChanged)
140
+ eventTarget?.addEventListener('offline', current.connectionChanged)
141
+ eventTarget?.addEventListener('focus', current.wake)
142
+ document?.addEventListener('visibilitychange', current.visible)
143
+ queueMicrotask(current.wake)
144
+ }
145
+
146
+ function stop () {
147
+ const current = session
148
+ session = null
149
+ if (current.timer != null) clearTimer(current.timer)
150
+ current.controller.abort()
151
+ eventTarget?.removeEventListener('online', current.connectionChanged)
152
+ eventTarget?.removeEventListener('offline', current.connectionChanged)
153
+ eventTarget?.removeEventListener('focus', current.wake)
154
+ document?.removeEventListener('visibilitychange', current.visible)
155
+ }
156
+
157
+ // Notify once on confirmed initial connectivity, then after each detected reconnection.
158
+ function onOnline (handler) {
159
+ if (typeof handler !== 'function') throw new ValidationError('INVALID_ONLINE_HANDLER')
160
+ const entry = { handler, delivered: false }
161
+ listeners.add(entry)
162
+ if (!session) start()
163
+ else {
164
+ const current = session
165
+ queueMicrotask(() => { probe(current) })
166
+ }
167
+ return () => {
168
+ if (!listeners.delete(entry)) return
169
+ if (!listeners.size) stop()
170
+ }
171
+ }
172
+
173
+ return { onOnline }
62
174
  }
63
175
 
176
+ let defaultMonitor
177
+
178
+ // Share probes, capped backoff and wake-up listeners across callers in this realm.
64
179
  export function onOnline (handler) {
65
- const listener = () => handler()
66
- window.addEventListener('online', listener)
67
- return () => window.removeEventListener('online', listener)
180
+ defaultMonitor ??= createConnectivityMonitor()
181
+ return defaultMonitor.onOnline(handler)
68
182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.10",
3
+ "version": "0.10.11",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",