libp2r2p 0.10.22 → 0.10.24

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
@@ -820,6 +820,24 @@ The NIP-94 extension also carries optional `download` intent; see
820
820
 
821
821
  ## Reliable IndexedDB queue reservations
822
822
 
823
+ `queue.putBy(indexName, item, { existingOnly = false })` atomically inserts or
824
+ replaces an item through a declared unique, non-multiEntry index. Its key is
825
+ extracted from the item's index keyPath, including nested paths and compound
826
+ keys. Existing items retain their position; new keys append to the tail. It
827
+ returns `true` after commit, or `false` without inserting when `existingOnly`
828
+ is true and the key is absent. This allows late progress checkpoints to avoid
829
+ recreating a cancelled record. Values are cloned before asynchronous work.
830
+
831
+ Unknown, non-unique or multiEntry indexes throw `ValidationError` with
832
+ `QUEUE_PUT_INDEX_INVALID`; missing or invalid IndexedDB keys use
833
+ `QUEUE_PUT_KEY_INVALID`. Cloning, storage and other unique-index failures retain
834
+ their operational errors. Lookup, replacement and byte accounting share one
835
+ transaction and roll back together. Capacity policy matches `setAt` for a
836
+ replacement and `push` for an insertion. Replacing a reserved item invalidates
837
+ the old token: its `ack`, `nack` and `renew` return false. Successful writes wake
838
+ local waiting consumers. Like the other indexed methods, `putBy` is exclusive
839
+ to `idb-queue`; the Web Storage queue's common API is unchanged.
840
+
823
841
  `createQueue({ prefix, evictionPolicy: 'reject', maxBytes })` opts out of
824
842
  capacity eviction. Oversized items reject with `QUEUE_ITEM_TOO_LARGE`, full
825
843
  queues with `QUEUE_CAPACITY_EXCEEDED`; browser quota errors propagate without
@@ -725,6 +725,34 @@ export async function createQueue ({
725
725
  })
726
726
  }
727
727
 
728
+ async function putBy (indexName, item, { existingOnly = false } = {}) {
729
+ const definition = indexDefinitions.find(index => index.name === indexName)
730
+ if (!definition?.unique || definition.multiEntry) throw new ValidationError('QUEUE_PUT_INDEX_INVALID')
731
+ // Snapshot before awaiting IndexedDB so the lookup key and stored value
732
+ // cannot diverge if the caller changes its object during the transaction.
733
+ const value = structuredClone(item)
734
+ const readKey = path => path.split('.').reduce((part, key) => part?.[key], value)
735
+ const key = Array.isArray(definition.keyPath) ? definition.keyPath.map(readKey) : readKey(definition.keyPath)
736
+ try { indexedDB.cmp(key, key) } catch { throw new ValidationError('QUEUE_PUT_KEY_INVALID') }
737
+ const requiredBytes = itemForStorage(0, value).byteSize
738
+ const written = await mutate(async (tx, state) => {
739
+ const { result: previous } = await run('get', [key], ITEMS_STORE, indexName, { tx })
740
+ if (previous) {
741
+ const index = previous.position - state.head
742
+ await putItem(tx, state, previous.position, value, {
743
+ direction: evictionDirectionFor('setAt', { index, length: state.tail - state.head }),
744
+ protectedPositions: new Set([previous.position])
745
+ })
746
+ } else {
747
+ if (existingOnly) return false
748
+ await pushInTransaction(tx, state, value)
749
+ }
750
+ return true
751
+ }, { requiredBytes })
752
+ if (written) wake()
753
+ return written
754
+ }
755
+
728
756
  async function getBy (indexName, query) {
729
757
  // Explicit index operations avoid scanning queue values in JavaScript.
730
758
  return snapshot(async tx => {
@@ -899,6 +927,7 @@ export async function createQueue ({
899
927
  removeWhere,
900
928
  some,
901
929
  clear,
930
+ putBy,
902
931
  getBy,
903
932
  someBy,
904
933
  removeBy,
package/network/README.md CHANGED
@@ -1,18 +1,30 @@
1
1
  # Connectivity
2
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.
3
+ `isOnline({ signal, strict = false } = {})` returns a boolean after probing
4
+ public connectivity endpoints, or immediately returns `false` when the browser
5
+ reports offline. The first candidate probes alone with a 2.5-second timeout; if
6
+ it is still pending after 1 second, the remaining candidates start in parallel
7
+ with four-second timeouts and the first success wins, keeping the worst case near
8
+ five seconds instead of serializing every timeout. Concurrent calls without a
9
+ signal share one check per mode. A supplied abort signal cancels the caller's
10
+ independent check immediately.
11
+
12
+ By default a probe only proves the endpoint is reachable (`no-cors`), so HTTP
13
+ errors and captive portals still resolve. With `strict: true`, probes use
14
+ CORS-enabled endpoints and require `response.ok` plus the expected body marker,
15
+ which rejects captive portals and error pages. `createConnectivityMonitor({
16
+ strict })` and `onOnline(handler, { strict })` select the same mode; shared
17
+ checks, monitors and listeners are kept per mode.
7
18
 
8
19
  `onOnline(handler)` returns an idempotent unsubscribe function. It notifies
9
20
  asynchronously after confirmed initial connectivity and each detected recovery.
10
21
  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 })`.
22
+ Listeners of a mode share a monitor with retries at 5, 15, 30, then 60 seconds
23
+ (20% jitter), checks on focus/visibility/network changes, and a 60-second interval
24
+ while online. Unsubscribing the last listener removes timers/listeners and aborts
25
+ its probe. Handler failures are isolated and logged.
26
+ `createConnectivityMonitor(options)` creates an independent monitor with an
27
+ optional custom `check({ signal })` and `strict` default.
16
28
 
17
29
  Successful probes do not guarantee a particular relay or HTTP server is reachable.
18
30
  Handle request failures, cancellations, and timeouts separately. Browsers can
package/network/index.js CHANGED
@@ -7,28 +7,74 @@ const CONNECTIVITY_PROBE_URLS = [
7
7
  { url: 'https://captive.apple.com/hotspot-detect.html' },
8
8
  { method: 'GET', url: 'https://connectivity-check.ubuntu.com' }
9
9
  ]
10
- let sharedCheck
10
+ // CORS-enabled endpoints let the strict mode validate the response instead of
11
+ // merely proving the host is reachable.
12
+ const STRICT_CONNECTIVITY_PROBE_URLS = [
13
+ { url: 'https://captive.apple.com/hotspot-detect.html', method: 'GET', marker: 'Success' },
14
+ { url: 'https://1.1.1.1/cdn-cgi/trace', method: 'GET', marker: 'ip=' },
15
+ { url: 'https://cloudflare.com/cdn-cgi/trace', method: 'GET', marker: 'ip=' }
16
+ ]
17
+ const FIRST_PROBE_TIMEOUT_MS = 2500
18
+ const HEDGE_DELAY_MS = 1000
19
+ const REMAINING_PROBE_TIMEOUT_MS = 4000
20
+ const sharedChecks = new Map()
11
21
 
12
- // Treat the browser's offline flag as a fast failure; confirm online status with a probe.
13
- export async function isOnline ({ signal } = {}) {
22
+ // Treat the browser's offline flag as a fast failure; confirm online status with
23
+ // probes. The default mode only proves the hosts are reachable, while strict mode
24
+ // requires CORS responses with an expected body and rejects captive portals.
25
+ export async function isOnline ({ signal, strict = false } = {}) {
14
26
  if (signal?.aborted) throw signal.reason
15
27
  if (globalThis.navigator?.onLine === false) return false
16
- if (signal) return hasInternetConnectivity(signal)
17
- sharedCheck ??= hasInternetConnectivity().finally(() => { sharedCheck = null })
18
- return sharedCheck
28
+ if (signal) return hasInternetConnectivity(signal, strict)
29
+ const key = strict ? 'strict' : 'lenient'
30
+ if (!sharedChecks.has(key)) {
31
+ sharedChecks.set(key, hasInternetConnectivity(undefined, strict).finally(() => { sharedChecks.delete(key) }))
32
+ }
33
+ return sharedChecks.get(key)
19
34
  }
20
35
 
21
- async function hasInternetConnectivity (signal) {
22
- for (const candidate of shuffle(CONNECTIVITY_PROBE_URLS)) {
23
- if (signal?.aborted) throw signal.reason
36
+ // The first candidate answers the common case alone. When it is still pending
37
+ // after HEDGE_DELAY_MS, the remaining candidates race in parallel so one slow or
38
+ // blocked host cannot serialize every timeout.
39
+ async function hasInternetConnectivity (signal, strict) {
40
+ if (signal?.aborted) throw signal.reason
41
+ const candidates = shuffle(strict ? STRICT_CONNECTIVITY_PROBE_URLS : CONNECTIVITY_PROBE_URLS)
42
+ const [first, ...rest] = candidates
43
+ if (!first) return false
44
+ const controller = new AbortController()
45
+ const onAbort = () => controller.abort(signal?.reason)
46
+ signal?.addEventListener('abort', onAbort, { once: true })
47
+ if (signal?.aborted) onAbort()
48
+ let hedgeTimer
49
+ let onHedgeAbort
50
+ try {
51
+ let startHedge
52
+ const firstAttempt = ping(first, { strict, signal: controller.signal, timeout: FIRST_PROBE_TIMEOUT_MS })
53
+ const hedge = new Promise((resolve, reject) => {
54
+ startHedge = resolve
55
+ hedgeTimer = setTimeout(resolve, HEDGE_DELAY_MS)
56
+ if (signal) {
57
+ onHedgeAbort = () => reject(signal.reason)
58
+ signal.addEventListener('abort', onHedgeAbort, { once: true })
59
+ if (signal.aborted) onHedgeAbort()
60
+ }
61
+ }).then(() => Promise.any(rest.map(candidate => ping(candidate, { strict, signal: controller.signal, timeout: REMAINING_PROBE_TIMEOUT_MS }))))
62
+ // A definitive failure releases the hedge immediately; a slow probe waits
63
+ // for the hedge delay so a working network still costs one request.
64
+ firstAttempt.catch(() => startHedge())
24
65
  try {
25
- await ping(candidate.url, { method: candidate.method, signal })
66
+ await Promise.any([firstAttempt, hedge])
26
67
  return true
27
68
  } catch {
28
69
  if (signal?.aborted) throw signal.reason
70
+ return false
29
71
  }
72
+ } finally {
73
+ clearTimeout(hedgeTimer)
74
+ if (onHedgeAbort) signal?.removeEventListener('abort', onHedgeAbort)
75
+ controller.abort()
76
+ signal?.removeEventListener('abort', onAbort)
30
77
  }
31
- return false
32
78
  }
33
79
 
34
80
  function shuffle (list) {
@@ -41,7 +87,8 @@ function shuffle (list) {
41
87
  }
42
88
 
43
89
  // Bound each probe and release its timer and abort listener on every exit path.
44
- async function ping (url, { method = 'HEAD', timeout = 5000, signal } = {}) {
90
+ async function ping (candidate, { strict = false, timeout, signal } = {}) {
91
+ if (signal?.aborted) throw signal.reason
45
92
  const controller = new AbortController()
46
93
  let timer
47
94
  let onAbort
@@ -58,19 +105,29 @@ async function ping (url, { method = 'HEAD', timeout = 5000, signal } = {}) {
58
105
  if (signal?.aborted) onAbort()
59
106
  })
60
107
  try {
61
- await Promise.race([
62
- fetch(url, { method, mode: 'no-cors', cache: 'no-store', redirect: 'follow', signal: controller.signal }),
108
+ const response = await Promise.race([
109
+ fetch(candidate.url, {
110
+ method: candidate.method ?? (strict ? 'GET' : 'HEAD'),
111
+ mode: strict ? 'cors' : 'no-cors',
112
+ cache: 'no-store',
113
+ redirect: 'follow',
114
+ signal: controller.signal
115
+ }),
63
116
  stopped
64
117
  ])
118
+ if (!strict) return
119
+ if (!response.ok) throw new Error('PING_STATUS')
120
+ if (!(await response.text()).includes(candidate.marker)) throw new Error('PING_BODY')
65
121
  } finally {
66
122
  clearTimeout(timer)
67
123
  signal?.removeEventListener('abort', onAbort)
68
124
  }
69
125
  }
70
126
 
71
- // A monitor owns one probe loop, regardless of how many consumers subscribe.
127
+ // A monitor owns one probe loop per mode, regardless of how many consumers subscribe.
72
128
  export function createConnectivityMonitor ({
73
- check = isOnline,
129
+ check,
130
+ strict = false,
74
131
  eventTarget = globalThis.window,
75
132
  document = globalThis.document,
76
133
  _setTimeout = globalThis.setTimeout,
@@ -78,7 +135,8 @@ export function createConnectivityMonitor ({
78
135
  _random = Math.random,
79
136
  reportError = error => console.error('Online listener failed', error)
80
137
  } = {}) {
81
- if (typeof check !== 'function') throw new ValidationError('INVALID_CONNECTIVITY_CHECK')
138
+ if (check !== undefined && typeof check !== 'function') throw new ValidationError('INVALID_CONNECTIVITY_CHECK')
139
+ const runCheck = check ?? (options => isOnline({ ...options, strict }))
82
140
  const listeners = new Set()
83
141
  const setTimer = (...args) => Reflect.apply(_setTimeout, globalThis, args)
84
142
  const clearTimer = (...args) => Reflect.apply(_clearTimeout, globalThis, args)
@@ -106,7 +164,7 @@ export function createConnectivityMonitor ({
106
164
  current.timer = null
107
165
  current.pending = true
108
166
  try {
109
- const online = await check({ signal: current.controller.signal })
167
+ const online = await runCheck({ signal: current.controller.signal })
110
168
  if (session !== current) return
111
169
  current.online = online === true && globalThis.navigator?.onLine !== false
112
170
  if (current.online) {
@@ -173,10 +231,15 @@ export function createConnectivityMonitor ({
173
231
  return { onOnline }
174
232
  }
175
233
 
176
- let defaultMonitor
234
+ const defaultMonitors = new Map()
177
235
 
178
- // Share probes, capped backoff and wake-up listeners across callers in this realm.
179
- export function onOnline (handler) {
180
- defaultMonitor ??= createConnectivityMonitor()
181
- return defaultMonitor.onOnline(handler)
236
+ // Share probes, capped backoff and wake-up listeners per mode in this realm.
237
+ export function onOnline (handler, { strict = false } = {}) {
238
+ const key = strict ? 'strict' : 'lenient'
239
+ let monitor = defaultMonitors.get(key)
240
+ if (!monitor) {
241
+ monitor = createConnectivityMonitor({ strict })
242
+ defaultMonitors.set(key, monitor)
243
+ }
244
+ return monitor.onOnline(handler)
182
245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.10.22",
3
+ "version": "0.10.24",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",