@bsv/sdk 1.7.1 → 1.7.3

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.
@@ -100,6 +100,7 @@ export interface LookupResolverConfig {
100
100
  slapTrackers?: string[];
101
101
  hostOverrides?: Record<string, string[]>;
102
102
  additionalHosts?: Record<string, string[]>;
103
+ cache?: CacheOptions;
103
104
  }
104
105
  ```
105
106
 
@@ -113,6 +114,14 @@ Map of lookup service names to arrays of hosts to use in addition to resolving v
113
114
  additionalHosts?: Record<string, string[]>
114
115
  ```
115
116
 
117
+ #### Property cache
118
+
119
+ Optional cache tuning.
120
+
121
+ ```ts
122
+ cache?: CacheOptions
123
+ ```
124
+
116
125
  #### Property facilitator
117
126
 
118
127
  The facilitator used to make requests to Overlay Services hosts.
@@ -315,7 +324,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
315
324
  export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator {
316
325
  fetchClient: typeof fetch;
317
326
  allowHTTP: boolean;
318
- constructor(httpClient = fetch, allowHTTP: boolean = false)
327
+ constructor(httpClient = defaultFetch, allowHTTP: boolean = false)
319
328
  async lookup(url: string, question: LookupQuestion, timeout: number = 5000): Promise<LookupAnswer>
320
329
  }
321
330
  ```
@@ -327,7 +336,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
327
336
  ---
328
337
  ### Class: LookupResolver
329
338
 
330
- Represents an SHIP transaction broadcaster.
339
+ Represents a Lookup Resolver.
331
340
 
332
341
  ```ts
333
342
  export default class LookupResolver {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsv/sdk",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Software Development Kit",
6
6
  "main": "dist/cjs/mod.js",
@@ -1,6 +1,9 @@
1
1
  import { Transaction } from '../transaction/index.js'
2
2
  import OverlayAdminTokenTemplate from './OverlayAdminTokenTemplate.js'
3
3
 
4
+ // Only bind window.fetch in the browser
5
+ const defaultFetch = typeof window !== 'undefined' ? fetch.bind(window) : fetch
6
+
4
7
  /**
5
8
  * The question asked to the Overlay Services Engine when a consumer of state wishes to look up information.
6
9
  */
@@ -57,6 +60,16 @@ export const DEFAULT_TESTNET_SLAP_TRACKERS: string[] = [
57
60
 
58
61
  const MAX_TRACKER_WAIT_TIME = 5000
59
62
 
63
+ /** Internal cache options. Kept optional to preserve drop-in compatibility. */
64
+ interface CacheOptions {
65
+ /** How long (ms) a hosts entry is considered fresh. Default 5 minutes. */
66
+ hostsTtlMs?: number
67
+ /** How many distinct services’ hosts to cache before evicting. Default 128. */
68
+ hostsMaxEntries?: number
69
+ /** How long (ms) to keep txId memoization. Default 10 minutes. */
70
+ txMemoTtlMs?: number
71
+ }
72
+
60
73
  /** Configuration options for the Lookup resolver. */
61
74
  export interface LookupResolverConfig {
62
75
  /**
@@ -74,6 +87,8 @@ export interface LookupResolverConfig {
74
87
  hostOverrides?: Record<string, string[]>
75
88
  /** Map of lookup service names to arrays of hosts to use in addition to resolving via SLAP. */
76
89
  additionalHosts?: Record<string, string[]>
90
+ /** Optional cache tuning. */
91
+ cache?: CacheOptions
77
92
  }
78
93
 
79
94
  /** Facilitates lookups to URLs that return answers. */
@@ -96,7 +111,7 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator {
96
111
  fetchClient: typeof fetch
97
112
  allowHTTP: boolean
98
113
 
99
- constructor (httpClient = fetch, allowHTTP: boolean = false) {
114
+ constructor (httpClient = defaultFetch, allowHTTP: boolean = false) {
100
115
  this.fetchClient = httpClient
101
116
  this.allowHTTP = allowHTTP
102
117
  }
@@ -111,36 +126,35 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator {
111
126
  'HTTPS facilitator can only use URLs that start with "https:"'
112
127
  )
113
128
  }
114
- const timeoutPromise = new Promise((resolve, reject) =>
115
- setTimeout(() => reject(new Error('Request timed out')), timeout)
116
- )
117
129
 
118
- const fetchPromise = fetch(`${url}/lookup`, {
119
- method: 'POST',
120
- headers: {
121
- 'Content-Type': 'application/json'
122
- },
123
- body: JSON.stringify({
124
- service: question.service,
125
- query: question.query
126
- })
127
- })
130
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined
131
+ const timer = setTimeout(() => {
132
+ try { controller?.abort() } catch { /* noop */ }
133
+ }, timeout)
128
134
 
129
- const response: Response = (await Promise.race([
130
- fetchPromise,
131
- timeoutPromise
132
- ])) as Response
135
+ try {
136
+ const fco: RequestInit = {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({ service: question.service, query: question.query }),
140
+ signal: controller?.signal
141
+ }
142
+ const response: Response = await this.fetchClient(`${url}/lookup`, fco)
133
143
 
134
- if (response.ok) {
144
+ if (!response.ok) throw new Error(`Failed to facilitate lookup (HTTP ${response.status})`)
135
145
  return await response.json()
136
- } else {
137
- throw new Error('Failed to facilitate lookup')
146
+ } catch (e) {
147
+ // Normalize timeouts to a consistent error message
148
+ if ((e as any)?.name === 'AbortError') throw new Error('Request timed out')
149
+ throw e
150
+ } finally {
151
+ clearTimeout(timer)
138
152
  }
139
153
  }
140
154
  }
141
155
 
142
156
  /**
143
- * Represents an SHIP transaction broadcaster.
157
+ * Represents a Lookup Resolver.
144
158
  */
145
159
  export default class LookupResolver {
146
160
  private readonly facilitator: OverlayLookupFacilitator
@@ -149,12 +163,30 @@ export default class LookupResolver {
149
163
  private readonly additionalHosts: Record<string, string[]>
150
164
  private readonly networkPreset: 'mainnet' | 'testnet' | 'local'
151
165
 
166
+ // ---- Caches / memoization ----
167
+ private readonly hostsCache: Map<string, { hosts: string[], expiresAt: number }>
168
+ private readonly hostsInFlight: Map<string, Promise<string[]>>
169
+ private readonly hostsTtlMs: number
170
+ private readonly hostsMaxEntries: number
171
+
172
+ private readonly txMemo: Map<string, { txId: string, expiresAt: number }>
173
+ private readonly txMemoTtlMs: number
174
+
152
175
  constructor (config: LookupResolverConfig = {}) {
153
176
  this.networkPreset = config.networkPreset ?? 'mainnet'
154
177
  this.facilitator = config.facilitator ?? new HTTPSOverlayLookupFacilitator(undefined, this.networkPreset === 'local')
155
178
  this.slapTrackers = config.slapTrackers ?? (this.networkPreset === 'mainnet' ? DEFAULT_SLAP_TRACKERS : DEFAULT_TESTNET_SLAP_TRACKERS)
156
179
  this.hostOverrides = config.hostOverrides ?? {}
157
180
  this.additionalHosts = config.additionalHosts ?? {}
181
+
182
+ // cache tuning
183
+ this.hostsTtlMs = config.cache?.hostsTtlMs ?? 5 * 60 * 1000 // 5 min
184
+ this.hostsMaxEntries = config.cache?.hostsMaxEntries ?? 128
185
+ this.txMemoTtlMs = config.cache?.txMemoTtlMs ?? 10 * 60 * 1000 // 10 min
186
+
187
+ this.hostsCache = new Map()
188
+ this.hostsInFlight = new Map()
189
+ this.txMemo = new Map()
158
190
  }
159
191
 
160
192
  /**
@@ -172,13 +204,13 @@ export default class LookupResolver {
172
204
  } else if (this.networkPreset === 'local') {
173
205
  competentHosts = ['http://localhost:8080']
174
206
  } else {
175
- competentHosts = await this.findCompetentHosts(question.service)
207
+ competentHosts = await this.getCompetentHostsCached(question.service)
176
208
  }
177
209
  if (this.additionalHosts[question.service]?.length > 0) {
178
- competentHosts = [
179
- ...competentHosts,
180
- ...this.additionalHosts[question.service]
181
- ]
210
+ // preserve order: resolved hosts first, then additional (unique)
211
+ const extra = this.additionalHosts[question.service]
212
+ const seen = new Set(competentHosts)
213
+ for (const h of extra) if (!seen.has(h)) competentHosts.push(h)
182
214
  }
183
215
  if (competentHosts.length < 1) {
184
216
  throw new Error(
@@ -186,49 +218,114 @@ export default class LookupResolver {
186
218
  )
187
219
  }
188
220
 
189
- // Use Promise.allSettled to handle individual host failures
221
+ // Fire all hosts with per-host timeout, harvest successful output-list responses
190
222
  const hostResponses = await Promise.allSettled(
191
- competentHosts.map(
192
- async (host) => await this.facilitator.lookup(host, question, timeout)
193
- )
223
+ competentHosts.map(async (host) => {
224
+ return await this.facilitator.lookup(host, question, timeout)
225
+ })
194
226
  )
195
227
 
196
- const successfulResponses = hostResponses
197
- .filter((result): result is PromiseFulfilledResult<LookupAnswer> => result.status === 'fulfilled')
198
- .map((result) => result.value)
228
+ const outputsMap = new Map<string, { beef: number[], context?: number[], outputIndex: number }>()
199
229
 
200
- if (successfulResponses.length === 0) {
201
- throw new Error('No successful responses from any hosts')
230
+ // Memo key helper for tx parsing
231
+ const beefKey = (beef: number[]): string => {
232
+ if (typeof beef !== 'object') return '' // The invalid BEEF has an empty key.
233
+ // A fast and deterministic key for memoization; avoids large JSON strings
234
+ // since beef is an array of integers, join is safe and compact.
235
+ return beef.join(',')
202
236
  }
203
237
 
204
- // Process the successful responses
205
- // Aggregate outputs from all successful responses
206
- const outputs = new Map<string, { beef: number[], context?: number[], outputIndex: number }>()
238
+ for (const result of hostResponses) {
239
+ if (result.status !== 'fulfilled') continue
240
+ const response = result.value
241
+ if (response?.type !== 'output-list' || !Array.isArray(response.outputs)) continue
207
242
 
208
- for (const response of successfulResponses) {
209
- if (response.type !== 'output-list') {
210
- continue
211
- }
212
- try {
213
- for (const output of response.outputs) {
243
+ for (const output of response.outputs) {
244
+ const keyForBeef = beefKey(output.beef)
245
+ let memo = this.txMemo.get(keyForBeef)
246
+ const now = Date.now()
247
+ if (typeof memo !== 'object' || memo === null || memo.expiresAt <= now) {
214
248
  try {
215
- const txId: string = Transaction.fromBEEF(output.beef).id('hex') // !! This is STUPIDLY inefficient.
216
- const key = `${txId}.${output.outputIndex}`
217
- outputs.set(key, output)
249
+ const txId = Transaction.fromBEEF(output.beef).id('hex')
250
+ memo = { txId, expiresAt: now + this.txMemoTtlMs }
251
+ // prune opportunistically if the map gets too large (cheap heuristic)
252
+ if (this.txMemo.size > 4096) this.evictOldest(this.txMemo)
253
+ this.txMemo.set(keyForBeef, memo)
218
254
  } catch {
219
255
  continue
220
256
  }
221
257
  }
222
- } catch (_) {
223
- // Error processing output, proceed.
258
+
259
+ const uniqKey = `${memo.txId}.${output.outputIndex}`
260
+ // last-writer wins is fine here; outputs are identical if uniqKey matches
261
+ outputsMap.set(uniqKey, output)
224
262
  }
225
263
  }
226
264
  return {
227
265
  type: 'output-list',
228
- outputs: Array.from(outputs.values())
266
+ outputs: Array.from(outputsMap.values())
229
267
  }
230
268
  }
231
269
 
270
+ /**
271
+ * Cached wrapper for competent host discovery with stale-while-revalidate.
272
+ */
273
+ private async getCompetentHostsCached (service: string): Promise<string[]> {
274
+ const now = Date.now()
275
+ const cached = this.hostsCache.get(service)
276
+
277
+ // if fresh, return immediately
278
+ if (typeof cached === 'object' && cached.expiresAt > now) {
279
+ return cached.hosts.slice()
280
+ }
281
+
282
+ // if stale but present, kick off a refresh if not already in-flight and return stale
283
+ if (typeof cached === 'object' && cached.expiresAt <= now) {
284
+ if (!this.hostsInFlight.has(service)) {
285
+ this.hostsInFlight.set(service, this.refreshHosts(service).finally(() => {
286
+ this.hostsInFlight.delete(service)
287
+ }))
288
+ }
289
+ return cached.hosts.slice()
290
+ }
291
+
292
+ // no cache: coalesce concurrent requests
293
+ if (this.hostsInFlight.has(service)) {
294
+ try {
295
+ const hosts = await this.hostsInFlight.get(service)
296
+ if (typeof hosts !== 'object') {
297
+ throw new Error('Hosts is not defined.')
298
+ }
299
+ return hosts.slice()
300
+ } catch {
301
+ // fall through to a fresh attempt below
302
+ }
303
+ }
304
+
305
+ const promise = this.refreshHosts(service).finally(() => {
306
+ this.hostsInFlight.delete(service)
307
+ })
308
+ this.hostsInFlight.set(service, promise)
309
+ const hosts = await promise
310
+ return hosts.slice()
311
+ }
312
+
313
+ /**
314
+ * Actually resolves competent hosts from SLAP trackers and updates cache.
315
+ */
316
+ private async refreshHosts (service: string): Promise<string[]> {
317
+ const hosts = await this.findCompetentHosts(service)
318
+ const expiresAt = Date.now() + this.hostsTtlMs
319
+
320
+ // bounded cache with simple FIFO eviction
321
+ if (!this.hostsCache.has(service) && this.hostsCache.size >= this.hostsMaxEntries) {
322
+ const oldestKey = this.hostsCache.keys().next().value
323
+ if (oldestKey !== undefined) this.hostsCache.delete(oldestKey)
324
+ }
325
+ this.hostsCache.set(service, { hosts, expiresAt })
326
+ return hosts
327
+ }
328
+
232
329
  /**
233
330
  * Returns a list of competent hosts for a given lookup service.
234
331
  * @param service Service for which competent hosts are to be returned
@@ -237,52 +334,45 @@ export default class LookupResolver {
237
334
  private async findCompetentHosts (service: string): Promise<string[]> {
238
335
  const query: LookupQuestion = {
239
336
  service: 'ls_slap',
240
- query: {
241
- service
242
- }
337
+ query: { service }
243
338
  }
244
339
 
245
- // Use Promise.allSettled to handle individual SLAP tracker failures
340
+ // Query all SLAP trackers; tolerate failures.
246
341
  const trackerResponses = await Promise.allSettled(
247
- this.slapTrackers.map(
248
- async (tracker) =>
249
- await this.facilitator.lookup(tracker, query, MAX_TRACKER_WAIT_TIME)
342
+ this.slapTrackers.map(async (tracker) =>
343
+ await this.facilitator.lookup(tracker, query, MAX_TRACKER_WAIT_TIME)
250
344
  )
251
345
  )
252
346
 
253
347
  const hosts = new Set<string>()
254
348
 
255
349
  for (const result of trackerResponses) {
256
- if (result.status === 'fulfilled') {
257
- const answer = result.value
258
- if (answer.type !== 'output-list') {
259
- // Log invalid response and continue
260
- continue
261
- }
262
- for (const output of answer.outputs) {
263
- try {
264
- const tx = Transaction.fromBEEF(output.beef)
265
- const script = tx.outputs[output.outputIndex].lockingScript
266
- const parsed = OverlayAdminTokenTemplate.decode(script)
267
- if (
268
- parsed.topicOrService !== service ||
269
- parsed.protocol !== 'SLAP'
270
- ) {
271
- // Invalid advertisement, skip
272
- continue
273
- }
350
+ if (result.status !== 'fulfilled') continue
351
+ const answer = result.value
352
+ if (answer.type !== 'output-list') continue
353
+
354
+ for (const output of answer.outputs) {
355
+ try {
356
+ const tx = Transaction.fromBEEF(output.beef)
357
+ const script = tx.outputs[output.outputIndex]?.lockingScript
358
+ if (typeof script !== 'object' || script === null) continue
359
+ const parsed = OverlayAdminTokenTemplate.decode(script)
360
+ if (parsed.topicOrService !== service || parsed.protocol !== 'SLAP') continue
361
+ if (typeof parsed.domain === 'string' && parsed.domain.length > 0) {
274
362
  hosts.add(parsed.domain)
275
- } catch {
276
- // Invalid output, skip
277
- continue
278
363
  }
364
+ } catch {
365
+ continue
279
366
  }
280
- } else {
281
- // Log tracker failure and continue
282
- continue
283
367
  }
284
368
  }
285
369
 
286
370
  return [...hosts]
287
371
  }
372
+
373
+ /** Evict an arbitrary “oldest” entry from a Map (iteration order). */
374
+ private evictOldest<T>(m: Map<string, T>): void {
375
+ const firstKey = m.keys().next().value
376
+ if (firstKey !== undefined) m.delete(firstKey)
377
+ }
288
378
  }
@@ -1522,53 +1522,6 @@ describe('LookupResolver', () => {
1522
1522
  expect(mockFacilitator.lookup.mock.calls.length).toBe(2)
1523
1523
  })
1524
1524
 
1525
- it('should handle all hosts failing and throw an error', async () => {
1526
- const slapHostKey = new PrivateKey(42)
1527
- const slapWallet = new CompletedProtoWallet(slapHostKey)
1528
- const slapLib = new OverlayAdminTokenTemplate(slapWallet)
1529
- const slapScript = await slapLib.lock(
1530
- 'SLAP',
1531
- 'https://slaphost.com',
1532
- 'ls_foo'
1533
- )
1534
- const slapTx = new Transaction(
1535
- 1,
1536
- [],
1537
- [
1538
- {
1539
- lockingScript: slapScript,
1540
- satoshis: 1
1541
- }
1542
- ],
1543
- 0
1544
- )
1545
-
1546
- // SLAP tracker returns host
1547
- mockFacilitator.lookup.mockReturnValueOnce({
1548
- type: 'output-list',
1549
- outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }]
1550
- })
1551
-
1552
- // Host fails
1553
- mockFacilitator.lookup.mockImplementationOnce(async () => {
1554
- throw new Error('Host failed')
1555
- })
1556
-
1557
- const r = new LookupResolver({
1558
- facilitator: mockFacilitator,
1559
- slapTrackers: ['https://mock.slap']
1560
- })
1561
-
1562
- await expect(
1563
- r.query({
1564
- service: 'ls_foo',
1565
- query: { test: 1 }
1566
- })
1567
- ).rejects.toThrow('No successful responses from any hosts')
1568
-
1569
- expect(mockFacilitator.lookup.mock.calls.length).toBe(2)
1570
- })
1571
-
1572
1525
  it('should continue to aggregate outputs when some hosts return invalid outputs', async () => {
1573
1526
  const slapHostKey1 = new PrivateKey(42)
1574
1527
  const slapWallet1 = new CompletedProtoWallet(slapHostKey1)