@bsv/sdk 2.0.2 → 2.0.4
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/dist/cjs/package.json +1 -1
- package/dist/cjs/src/auth/Peer.js +35 -33
- package/dist/cjs/src/auth/Peer.js.map +1 -1
- package/dist/cjs/src/auth/clients/AuthFetch.js +31 -5
- package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js +77 -0
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js.map +1 -1
- package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js +4 -1
- package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
- package/dist/cjs/src/identity/ContactsManager.js +26 -14
- package/dist/cjs/src/identity/ContactsManager.js.map +1 -1
- package/dist/cjs/src/identity/IdentityClient.js +12 -8
- package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
- package/dist/cjs/src/overlay-tools/LookupResolver.js +85 -41
- package/dist/cjs/src/overlay-tools/LookupResolver.js.map +1 -1
- package/dist/cjs/src/overlay-tools/SHIPBroadcaster.js +28 -4
- package/dist/cjs/src/overlay-tools/SHIPBroadcaster.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/auth/Peer.js +35 -33
- package/dist/esm/src/auth/Peer.js.map +1 -1
- package/dist/esm/src/auth/clients/AuthFetch.js +31 -5
- package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js +77 -0
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js.map +1 -1
- package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js +4 -1
- package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
- package/dist/esm/src/identity/ContactsManager.js +26 -14
- package/dist/esm/src/identity/ContactsManager.js.map +1 -1
- package/dist/esm/src/identity/IdentityClient.js +12 -8
- package/dist/esm/src/identity/IdentityClient.js.map +1 -1
- package/dist/esm/src/overlay-tools/LookupResolver.js +85 -41
- package/dist/esm/src/overlay-tools/LookupResolver.js.map +1 -1
- package/dist/esm/src/overlay-tools/SHIPBroadcaster.js +29 -4
- package/dist/esm/src/overlay-tools/SHIPBroadcaster.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/auth/Peer.d.ts.map +1 -1
- package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
- package/dist/types/src/auth/transports/SimplifiedFetchTransport.d.ts.map +1 -1
- package/dist/types/src/identity/ContactsManager.d.ts.map +1 -1
- package/dist/types/src/identity/IdentityClient.d.ts.map +1 -1
- package/dist/types/src/overlay-tools/LookupResolver.d.ts +6 -0
- package/dist/types/src/overlay-tools/LookupResolver.d.ts.map +1 -1
- package/dist/types/src/overlay-tools/SHIPBroadcaster.d.ts +8 -0
- package/dist/types/src/overlay-tools/SHIPBroadcaster.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +1 -1
- package/dist/umd/bundle.js.map +1 -1
- package/package.json +1 -1
- package/src/auth/Peer.ts +49 -47
- package/src/auth/__tests/Peer.test.ts +35 -30
- package/src/auth/clients/AuthFetch.ts +46 -18
- package/src/auth/clients/__tests__/AuthFetch.test.ts +97 -0
- package/src/auth/transports/SimplifiedFetchTransport.ts +24 -21
- package/src/identity/ContactsManager.ts +27 -15
- package/src/identity/IdentityClient.ts +13 -11
- package/src/identity/__tests/IdentityClient.test.ts +2 -2
- package/src/overlay-tools/LookupResolver.ts +83 -46
- package/src/overlay-tools/SHIPBroadcaster.ts +32 -4
- package/src/overlay-tools/__tests/LookupResolver.test.ts +36 -55
- package/src/overlay-tools/__tests/SHIPBroadcaster.test.ts +18 -6
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { AuthMessage, RequestedCertificateSet, Transport } from '../types.js'
|
|
4
4
|
import * as Utils from '../../primitives/utils.js'
|
|
5
5
|
|
|
6
|
-
const defaultFetch: typeof fetch =
|
|
6
|
+
const defaultFetch: typeof fetch =
|
|
7
7
|
typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function'
|
|
8
8
|
? globalThis.fetch.bind(globalThis)
|
|
9
9
|
: fetch
|
|
@@ -22,7 +22,7 @@ export class SimplifiedFetchTransport implements Transport {
|
|
|
22
22
|
* @param baseUrl - The base URL for all HTTP requests made by this transport.
|
|
23
23
|
* @param fetchClient - A fetch implementation to use for HTTP requests (default: global fetch).
|
|
24
24
|
*/
|
|
25
|
-
constructor
|
|
25
|
+
constructor(baseUrl: string, fetchClient = defaultFetch) {
|
|
26
26
|
if (typeof fetchClient !== 'function') {
|
|
27
27
|
throw new Error(
|
|
28
28
|
'SimplifiedFetchTransport requires a fetch implementation. ' +
|
|
@@ -44,7 +44,7 @@ export class SimplifiedFetchTransport implements Transport {
|
|
|
44
44
|
*
|
|
45
45
|
* @throws Will throw an error if no listener has been registered via `onData`.
|
|
46
46
|
*/
|
|
47
|
-
async send
|
|
47
|
+
async send(message: AuthMessage): Promise<void> {
|
|
48
48
|
if (this.onDataCallback == null) {
|
|
49
49
|
throw new Error('Listen before you start speaking. God gave you two ears and one mouth for a reason.')
|
|
50
50
|
}
|
|
@@ -240,24 +240,27 @@ export class SimplifiedFetchTransport implements Transport {
|
|
|
240
240
|
* @param callback - A function to invoke when an incoming AuthMessage is received.
|
|
241
241
|
* @returns A promise that resolves once the callback is set.
|
|
242
242
|
*/
|
|
243
|
-
async onData
|
|
243
|
+
async onData(callback: (message: AuthMessage) => Promise<void>): Promise<void> {
|
|
244
244
|
this.onDataCallback = (m) => {
|
|
245
|
-
void callback(m)
|
|
245
|
+
void callback(m).catch(() => {
|
|
246
|
+
// Errors from handleIncomingMessage on the client side are not
|
|
247
|
+
// actionable here — prevent unhandled promise rejections.
|
|
248
|
+
})
|
|
246
249
|
}
|
|
247
250
|
}
|
|
248
251
|
|
|
249
|
-
private createNetworkError
|
|
252
|
+
private createNetworkError(url: string, originalError: unknown): Error {
|
|
250
253
|
const baseMessage = `Network error while sending authenticated request to ${url}`
|
|
251
254
|
if (originalError instanceof Error) {
|
|
252
255
|
const error = new Error(`${baseMessage}: ${originalError.message}`)
|
|
253
256
|
error.stack = originalError.stack
|
|
254
|
-
|
|
257
|
+
; (error as any).cause = originalError
|
|
255
258
|
return error
|
|
256
259
|
}
|
|
257
260
|
return new Error(`${baseMessage}: ${String(originalError)}`)
|
|
258
261
|
}
|
|
259
262
|
|
|
260
|
-
private createUnauthenticatedResponseError
|
|
263
|
+
private createUnauthenticatedResponseError(
|
|
261
264
|
url: string,
|
|
262
265
|
response: Response,
|
|
263
266
|
bodyBytes: number[],
|
|
@@ -277,17 +280,17 @@ export class SimplifiedFetchTransport implements Transport {
|
|
|
277
280
|
}
|
|
278
281
|
|
|
279
282
|
const error = new Error(parts.join(' - '))
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
283
|
+
; (error as any).details = {
|
|
284
|
+
url,
|
|
285
|
+
status: response.status,
|
|
286
|
+
statusText: response.statusText,
|
|
287
|
+
missingHeaders,
|
|
288
|
+
bodyPreview
|
|
289
|
+
}
|
|
287
290
|
return error
|
|
288
291
|
}
|
|
289
292
|
|
|
290
|
-
private createMalformedHeaderError
|
|
293
|
+
private createMalformedHeaderError(
|
|
291
294
|
url: string,
|
|
292
295
|
headerName: string,
|
|
293
296
|
headerValue: string,
|
|
@@ -297,13 +300,13 @@ export class SimplifiedFetchTransport implements Transport {
|
|
|
297
300
|
if (cause instanceof Error) {
|
|
298
301
|
const error = new Error(`${errorMessage}. ${cause.message}`)
|
|
299
302
|
error.stack = cause.stack
|
|
300
|
-
|
|
303
|
+
; (error as any).cause = cause
|
|
301
304
|
return error
|
|
302
305
|
}
|
|
303
306
|
return new Error(`${errorMessage}. ${String(cause)}`)
|
|
304
307
|
}
|
|
305
308
|
|
|
306
|
-
private getBodyPreview
|
|
309
|
+
private getBodyPreview(bodyBytes: number[], contentType: string | null): string | undefined {
|
|
307
310
|
if (bodyBytes.length === 0) {
|
|
308
311
|
return undefined
|
|
309
312
|
}
|
|
@@ -333,7 +336,7 @@ export class SimplifiedFetchTransport implements Transport {
|
|
|
333
336
|
return preview
|
|
334
337
|
}
|
|
335
338
|
|
|
336
|
-
private isTextualContent
|
|
339
|
+
private isTextualContent(contentType: string | null, sample: number[]): boolean {
|
|
337
340
|
if (sample.length === 0) {
|
|
338
341
|
return false
|
|
339
342
|
}
|
|
@@ -367,7 +370,7 @@ export class SimplifiedFetchTransport implements Transport {
|
|
|
367
370
|
return (printableCount / sample.length) > 0.8
|
|
368
371
|
}
|
|
369
372
|
|
|
370
|
-
private formatBinaryPreview
|
|
373
|
+
private formatBinaryPreview(bytes: number[], truncated: boolean): string {
|
|
371
374
|
const hex = bytes.map(byte => byte.toString(16).padStart(2, '0')).join('')
|
|
372
375
|
return `0x${hex}${truncated ? '…' : ''}`
|
|
373
376
|
}
|
|
@@ -379,7 +382,7 @@ export class SimplifiedFetchTransport implements Transport {
|
|
|
379
382
|
* @returns An object representing the deserialized request, including the method,
|
|
380
383
|
* URL postfix (path and query string), headers, body, and request ID.
|
|
381
384
|
*/
|
|
382
|
-
deserializeRequestPayload
|
|
385
|
+
deserializeRequestPayload(payload: number[]): {
|
|
383
386
|
method: string
|
|
384
387
|
urlPostfix: string
|
|
385
388
|
headers: Record<string, string>
|
|
@@ -88,33 +88,45 @@ export class ContactsManager {
|
|
|
88
88
|
return []
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
// Pre-process outputs synchronously to extract decode data, then decrypt in parallel
|
|
92
|
+
const decryptTasks: Array<{ keyID: string, ciphertext: number[] }> = []
|
|
92
93
|
|
|
93
|
-
// Process each contact output
|
|
94
94
|
for (const output of outputs.outputs) {
|
|
95
95
|
try {
|
|
96
96
|
if (output.lockingScript == null) continue
|
|
97
|
-
|
|
98
|
-
// Decode the PushDrop data
|
|
99
97
|
const decoded = PushDrop.decode(LockingScript.fromHex(output.lockingScript))
|
|
100
98
|
if (output.customInstructions == null) continue
|
|
101
99
|
const keyID = JSON.parse(output.customInstructions).keyID
|
|
100
|
+
decryptTasks.push({ keyID, ciphertext: decoded.fields[0] })
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.warn('ContactsManager: Failed to decode contact output:', error)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
102
105
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
// Decrypt all contacts in parallel — each call is a network round-trip over localhost
|
|
107
|
+
const decryptResults = await Promise.allSettled(
|
|
108
|
+
decryptTasks.map(async task =>
|
|
109
|
+
await this.wallet.decrypt({
|
|
110
|
+
ciphertext: task.ciphertext,
|
|
106
111
|
protocolID: CONTACT_PROTOCOL_ID,
|
|
107
|
-
keyID,
|
|
112
|
+
keyID: task.keyID,
|
|
108
113
|
counterparty: 'self'
|
|
109
114
|
}, this.originator)
|
|
115
|
+
)
|
|
116
|
+
)
|
|
110
117
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
const contacts: Contact[] = []
|
|
119
|
+
for (let i = 0; i < decryptResults.length; i++) {
|
|
120
|
+
const result = decryptResults[i]
|
|
121
|
+
if (result.status === 'fulfilled') {
|
|
122
|
+
try {
|
|
123
|
+
const contactData: Contact = JSON.parse(Utils.toUTF8(result.value.plaintext))
|
|
124
|
+
contacts.push(contactData)
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.warn('ContactsManager: Failed to parse contact data:', error)
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
console.warn('ContactsManager: Failed to decrypt contact output:', result.reason)
|
|
118
130
|
}
|
|
119
131
|
}
|
|
120
132
|
|
|
@@ -138,19 +138,21 @@ export class IdentityClient {
|
|
|
138
138
|
args: DiscoverByIdentityKeyArgs,
|
|
139
139
|
overrideWithContacts = true
|
|
140
140
|
): Promise<DisplayableIdentity[]> {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
// Run both queries in parallel for better performance
|
|
142
|
+
const [contacts, certificatesResult] = await Promise.all([
|
|
143
|
+
overrideWithContacts
|
|
144
|
+
? this.contactsManager.getContacts(args.identityKey)
|
|
145
|
+
: Promise.resolve([]),
|
|
146
|
+
this.wallet.discoverByIdentityKey(args, this.originator)
|
|
147
|
+
])
|
|
148
|
+
|
|
149
|
+
// Override results with personal contacts if available
|
|
150
|
+
if (contacts.length > 0) {
|
|
151
|
+
return contacts
|
|
147
152
|
}
|
|
148
153
|
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
this.originator
|
|
152
|
-
)
|
|
153
|
-
return certificates.map((cert) => {
|
|
154
|
+
const certs = certificatesResult?.certificates ?? []
|
|
155
|
+
return certs.map((cert) => {
|
|
154
156
|
return IdentityClient.parseIdentity(cert)
|
|
155
157
|
})
|
|
156
158
|
}
|
|
@@ -264,8 +264,8 @@ describe('IdentityClient', () => {
|
|
|
264
264
|
|
|
265
265
|
expect(identities).toHaveLength(1)
|
|
266
266
|
expect(identities[0].name).toBe('Alice Smith (Personal Contact)') // Contact should be returned, not discovered identity
|
|
267
|
-
//
|
|
268
|
-
expect(walletMock.discoverByIdentityKey).
|
|
267
|
+
// Both calls fire in parallel, but contacts take priority in the result
|
|
268
|
+
expect(walletMock.discoverByIdentityKey).toHaveBeenCalled()
|
|
269
269
|
})
|
|
270
270
|
})
|
|
271
271
|
|
|
@@ -264,7 +264,6 @@ export default class LookupResolver {
|
|
|
264
264
|
competentHosts = await this.getCompetentHostsCached(question.service)
|
|
265
265
|
}
|
|
266
266
|
if (this.additionalHosts[question.service]?.length > 0) {
|
|
267
|
-
// preserve order: resolved hosts first, then additional (unique)
|
|
268
267
|
const extra = this.additionalHosts[question.service]
|
|
269
268
|
const seen = new Set(competentHosts)
|
|
270
269
|
for (const h of extra) if (!seen.has(h)) competentHosts.push(h)
|
|
@@ -283,28 +282,47 @@ export default class LookupResolver {
|
|
|
283
282
|
throw new Error(`All competent hosts for ${question.service} are temporarily unavailable due to backoff.`)
|
|
284
283
|
}
|
|
285
284
|
|
|
286
|
-
// Fire all hosts
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
285
|
+
// Fire all hosts; resolve as soon as we have results from any host,
|
|
286
|
+
// then allow a short grace window for additional hosts to contribute.
|
|
287
|
+
const GRACE_MS = 200
|
|
288
|
+
const answers: LookupAnswer[] = await new Promise<LookupAnswer[]>((resolve) => {
|
|
289
|
+
const collected: LookupAnswer[] = []
|
|
290
|
+
let pending = rankedHosts.length
|
|
291
|
+
let graceTimer: ReturnType<typeof setTimeout> | null = null
|
|
292
|
+
|
|
293
|
+
const tryResolve = (): void => {
|
|
294
|
+
if (graceTimer !== null) clearTimeout(graceTimer)
|
|
295
|
+
resolve(collected)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (const host of rankedHosts) {
|
|
299
|
+
this.lookupHostWithTracking(host, question, timeout)
|
|
300
|
+
.then((answer) => {
|
|
301
|
+
if (answer?.type === 'output-list' && Array.isArray(answer.outputs) && answer.outputs.length > 0) {
|
|
302
|
+
collected.push(answer)
|
|
303
|
+
// First valid response: start a grace window for others
|
|
304
|
+
if (collected.length === 1 && pending > 1) {
|
|
305
|
+
graceTimer = setTimeout(tryResolve, GRACE_MS)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
.catch(() => { /* host failed; tracked by lookupHostWithTracking */ })
|
|
310
|
+
.finally(() => {
|
|
311
|
+
pending--
|
|
312
|
+
if (pending === 0) tryResolve()
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
})
|
|
292
316
|
|
|
293
317
|
const outputsMap = new Map<string, { beef: number[], context?: number[], outputIndex: number }>()
|
|
294
318
|
|
|
295
319
|
// Memo key helper for tx parsing
|
|
296
320
|
const beefKey = (beef: number[]): string => {
|
|
297
321
|
if (typeof beef !== 'object') return '' // The invalid BEEF has an empty key.
|
|
298
|
-
// A fast and deterministic key for memoization; avoids large JSON strings
|
|
299
|
-
// since beef is an array of integers, join is safe and compact.
|
|
300
322
|
return beef.join(',')
|
|
301
323
|
}
|
|
302
324
|
|
|
303
|
-
for (const
|
|
304
|
-
if (result.status !== 'fulfilled') continue
|
|
305
|
-
const response = result.value
|
|
306
|
-
if (response?.type !== 'output-list' || !Array.isArray(response.outputs)) continue
|
|
307
|
-
|
|
325
|
+
for (const response of answers) {
|
|
308
326
|
for (const output of response.outputs) {
|
|
309
327
|
const keyForBeef = beefKey(output.beef)
|
|
310
328
|
let memo = this.txMemo.get(keyForBeef)
|
|
@@ -313,7 +331,6 @@ export default class LookupResolver {
|
|
|
313
331
|
try {
|
|
314
332
|
const txId = Transaction.fromBEEF(output.beef).id('hex')
|
|
315
333
|
memo = { txId, expiresAt: now + this.txMemoTtlMs }
|
|
316
|
-
// prune opportunistically if the map gets too large (cheap heuristic)
|
|
317
334
|
if (this.txMemo.size > 4096) this.evictOldest(this.txMemo)
|
|
318
335
|
this.txMemo.set(keyForBeef, memo)
|
|
319
336
|
} catch {
|
|
@@ -322,7 +339,6 @@ export default class LookupResolver {
|
|
|
322
339
|
}
|
|
323
340
|
|
|
324
341
|
const uniqKey = `${memo.txId}.${output.outputIndex}`
|
|
325
|
-
// last-writer wins is fine here; outputs are identical if uniqKey matches
|
|
326
342
|
outputsMap.set(uniqKey, output)
|
|
327
343
|
}
|
|
328
344
|
}
|
|
@@ -391,8 +407,33 @@ export default class LookupResolver {
|
|
|
391
407
|
return hosts
|
|
392
408
|
}
|
|
393
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Extracts competent host domains from a SLAP tracker response.
|
|
412
|
+
*/
|
|
413
|
+
private extractHostsFromAnswer (answer: LookupAnswer, service: string): string[] {
|
|
414
|
+
const hosts: string[] = []
|
|
415
|
+
if (answer.type !== 'output-list') return hosts
|
|
416
|
+
for (const output of answer.outputs) {
|
|
417
|
+
try {
|
|
418
|
+
const tx = Transaction.fromBEEF(output.beef)
|
|
419
|
+
const script = tx.outputs[output.outputIndex]?.lockingScript
|
|
420
|
+
if (typeof script !== 'object' || script === null) continue
|
|
421
|
+
const parsed = OverlayAdminTokenTemplate.decode(script)
|
|
422
|
+
if (parsed.topicOrService !== service || parsed.protocol !== 'SLAP') continue
|
|
423
|
+
if (typeof parsed.domain === 'string' && parsed.domain.length > 0) {
|
|
424
|
+
hosts.push(parsed.domain)
|
|
425
|
+
}
|
|
426
|
+
} catch {
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return hosts
|
|
431
|
+
}
|
|
432
|
+
|
|
394
433
|
/**
|
|
395
434
|
* Returns a list of competent hosts for a given lookup service.
|
|
435
|
+
* Resolves as soon as the first SLAP tracker responds with valid hosts.
|
|
436
|
+
* Remaining trackers continue in the background for reputation tracking.
|
|
396
437
|
* @param service Service for which competent hosts are to be returned
|
|
397
438
|
* @returns Array of hosts competent for resolving queries
|
|
398
439
|
*/
|
|
@@ -402,43 +443,39 @@ export default class LookupResolver {
|
|
|
402
443
|
query: { service }
|
|
403
444
|
}
|
|
404
445
|
|
|
405
|
-
// Query all SLAP trackers; tolerate failures.
|
|
406
446
|
const trackerHosts = this.prepareHostsForQuery(
|
|
407
447
|
this.slapTrackers,
|
|
408
448
|
'SLAP trackers'
|
|
409
449
|
)
|
|
410
450
|
if (trackerHosts.length === 0) return []
|
|
411
451
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
}
|
|
452
|
+
// Fire all trackers, resolve as soon as any returns valid hosts.
|
|
453
|
+
// Remaining trackers continue in the background for reputation tracking.
|
|
454
|
+
return await new Promise<string[]>((resolve) => {
|
|
455
|
+
const allHosts = new Set<string>()
|
|
456
|
+
let resolved = false
|
|
457
|
+
let pending = trackerHosts.length
|
|
458
|
+
|
|
459
|
+
for (const tracker of trackerHosts) {
|
|
460
|
+
this.lookupHostWithTracking(tracker, query, MAX_TRACKER_WAIT_TIME)
|
|
461
|
+
.then((answer) => {
|
|
462
|
+
const hosts = this.extractHostsFromAnswer(answer, service)
|
|
463
|
+
for (const h of hosts) allHosts.add(h)
|
|
464
|
+
if (!resolved && allHosts.size > 0) {
|
|
465
|
+
resolved = true
|
|
466
|
+
resolve([...allHosts])
|
|
467
|
+
}
|
|
468
|
+
})
|
|
469
|
+
.catch(() => { /* tracker failed; tracked by lookupHostWithTracking */ })
|
|
470
|
+
.finally(() => {
|
|
471
|
+
pending--
|
|
472
|
+
if (pending === 0 && !resolved) {
|
|
473
|
+
resolved = true
|
|
474
|
+
resolve([...allHosts])
|
|
475
|
+
}
|
|
476
|
+
})
|
|
438
477
|
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
return [...hosts]
|
|
478
|
+
})
|
|
442
479
|
}
|
|
443
480
|
|
|
444
481
|
/** Evict an arbitrary “oldest” entry from a Map (iteration order). */
|
|
@@ -133,6 +133,11 @@ export default class TopicBroadcaster implements Broadcaster {
|
|
|
133
133
|
private readonly requireAcknowledgmentFromSpecificHostsForTopics: Record<string, 'all' | 'any' | string[]>
|
|
134
134
|
private readonly networkPreset: 'mainnet' | 'testnet' | 'local'
|
|
135
135
|
|
|
136
|
+
// Cache for findInterestedHosts to avoid repeated SHIP tracker lookups
|
|
137
|
+
private interestedHostsCache: { hosts: Record<string, Set<string>>, expiresAt: number } | null = null
|
|
138
|
+
private interestedHostsInFlight: Promise<Record<string, Set<string>>> | null = null
|
|
139
|
+
private readonly interestedHostsTtlMs: number
|
|
140
|
+
|
|
136
141
|
/**
|
|
137
142
|
* Constructs an instance of the SHIP broadcaster.
|
|
138
143
|
*
|
|
@@ -156,6 +161,7 @@ export default class TopicBroadcaster implements Broadcaster {
|
|
|
156
161
|
config.requireAcknowledgmentFromAnyHostForTopics ?? 'all'
|
|
157
162
|
this.requireAcknowledgmentFromSpecificHostsForTopics =
|
|
158
163
|
config.requireAcknowledgmentFromSpecificHostsForTopics ?? {}
|
|
164
|
+
this.interestedHostsTtlMs = 5 * 60 * 1000 // 5 minutes
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
/**
|
|
@@ -461,10 +467,33 @@ export default class TopicBroadcaster implements Broadcaster {
|
|
|
461
467
|
}
|
|
462
468
|
return { 'http://localhost:8080': resultSet }
|
|
463
469
|
}
|
|
464
|
-
// TODO: cache the list of interested hosts to avoid spamming SHIP trackers.
|
|
465
|
-
// TODO: Monetize the operation of the SHIP tracker system.
|
|
466
|
-
// TODO: Cache ship/slap lookup with expiry (every 5min)
|
|
467
470
|
|
|
471
|
+
// Return cached result if still valid
|
|
472
|
+
const now = Date.now()
|
|
473
|
+
if (this.interestedHostsCache != null && this.interestedHostsCache.expiresAt > now) {
|
|
474
|
+
return this.interestedHostsCache.hosts
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Deduplicate concurrent requests
|
|
478
|
+
if (this.interestedHostsInFlight != null) {
|
|
479
|
+
return await this.interestedHostsInFlight
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
this.interestedHostsInFlight = this.fetchInterestedHosts()
|
|
483
|
+
try {
|
|
484
|
+
const hosts = await this.interestedHostsInFlight
|
|
485
|
+
this.interestedHostsCache = { hosts, expiresAt: Date.now() + this.interestedHostsTtlMs }
|
|
486
|
+
return hosts
|
|
487
|
+
} finally {
|
|
488
|
+
this.interestedHostsInFlight = null
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Performs the actual SHIP lookup to discover interested hosts.
|
|
494
|
+
* @private
|
|
495
|
+
*/
|
|
496
|
+
private async fetchInterestedHosts (): Promise<Record<string, Set<string>>> {
|
|
468
497
|
// Find all SHIP advertisements for the topics we care about
|
|
469
498
|
const results: Record<string, Set<string>> = {}
|
|
470
499
|
const answer = await this.resolver.query(
|
|
@@ -488,7 +517,6 @@ export default class TopicBroadcaster implements Broadcaster {
|
|
|
488
517
|
!this.topics.includes(parsed.topicOrService) ||
|
|
489
518
|
parsed.protocol !== 'SHIP'
|
|
490
519
|
) {
|
|
491
|
-
// This should make us think a LOT less highly of this SHIP tracker if it ever happens...
|
|
492
520
|
continue
|
|
493
521
|
}
|
|
494
522
|
if (results[parsed.domain] === undefined) {
|
|
@@ -367,7 +367,7 @@ describe('LookupResolver', () => {
|
|
|
367
367
|
])
|
|
368
368
|
})
|
|
369
369
|
|
|
370
|
-
it('should handle multiple SLAP trackers and
|
|
370
|
+
it('should handle multiple SLAP trackers and resolve with first responder hosts', async () => {
|
|
371
371
|
const slapHostKey1 = new PrivateKey(42)
|
|
372
372
|
const slapWallet1 = new CompletedProtoWallet(slapHostKey1)
|
|
373
373
|
const slapLib1 = new OverlayAdminTokenTemplate(slapWallet1)
|
|
@@ -408,7 +408,7 @@ describe('LookupResolver', () => {
|
|
|
408
408
|
0
|
|
409
409
|
)
|
|
410
410
|
|
|
411
|
-
// SLAP trackers return hosts
|
|
411
|
+
// SLAP trackers return hosts — first responder wins
|
|
412
412
|
mockFacilitator.lookup
|
|
413
413
|
.mockReturnValueOnce({
|
|
414
414
|
type: 'output-list',
|
|
@@ -429,7 +429,7 @@ describe('LookupResolver', () => {
|
|
|
429
429
|
]
|
|
430
430
|
})
|
|
431
431
|
|
|
432
|
-
//
|
|
432
|
+
// Only the first-resolved tracker's host gets queried
|
|
433
433
|
mockFacilitator.lookup
|
|
434
434
|
.mockReturnValueOnce({
|
|
435
435
|
type: 'output-list',
|
|
@@ -440,15 +440,6 @@ describe('LookupResolver', () => {
|
|
|
440
440
|
}
|
|
441
441
|
]
|
|
442
442
|
})
|
|
443
|
-
.mockReturnValueOnce({
|
|
444
|
-
type: 'output-list',
|
|
445
|
-
outputs: [
|
|
446
|
-
{
|
|
447
|
-
beef: sampleBeef4,
|
|
448
|
-
outputIndex: 1
|
|
449
|
-
}
|
|
450
|
-
]
|
|
451
|
-
})
|
|
452
443
|
|
|
453
444
|
const r = new LookupResolver({
|
|
454
445
|
facilitator: mockFacilitator,
|
|
@@ -460,55 +451,45 @@ describe('LookupResolver', () => {
|
|
|
460
451
|
query: { test: 1 }
|
|
461
452
|
})
|
|
462
453
|
|
|
454
|
+
// Only the first tracker's host results are returned
|
|
463
455
|
expect(res).toEqual({
|
|
464
456
|
type: 'output-list',
|
|
465
457
|
outputs: [
|
|
466
|
-
{ beef: sampleBeef3, outputIndex: 0 }
|
|
467
|
-
{ beef: sampleBeef4, outputIndex: 1 }
|
|
458
|
+
{ beef: sampleBeef3, outputIndex: 0 }
|
|
468
459
|
]
|
|
469
460
|
})
|
|
470
461
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
[
|
|
503
|
-
'https://slaphost2.com',
|
|
504
|
-
{
|
|
505
|
-
service: 'ls_foo',
|
|
506
|
-
query: {
|
|
507
|
-
test: 1
|
|
508
|
-
}
|
|
509
|
-
},
|
|
510
|
-
undefined
|
|
511
|
-
]
|
|
462
|
+
// Both SLAP trackers are queried, but only the first host is used for the actual query
|
|
463
|
+
expect(mockFacilitator.lookup.mock.calls.length).toBeGreaterThanOrEqual(3)
|
|
464
|
+
expect(mockFacilitator.lookup.mock.calls[0]).toEqual([
|
|
465
|
+
'https://mock.slap1',
|
|
466
|
+
{
|
|
467
|
+
service: 'ls_slap',
|
|
468
|
+
query: {
|
|
469
|
+
service: 'ls_foo'
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
5000
|
|
473
|
+
])
|
|
474
|
+
expect(mockFacilitator.lookup.mock.calls[1]).toEqual([
|
|
475
|
+
'https://mock.slap2',
|
|
476
|
+
{
|
|
477
|
+
service: 'ls_slap',
|
|
478
|
+
query: {
|
|
479
|
+
service: 'ls_foo'
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
5000
|
|
483
|
+
])
|
|
484
|
+
expect(mockFacilitator.lookup.mock.calls[2]).toEqual([
|
|
485
|
+
'https://slaphost1.com',
|
|
486
|
+
{
|
|
487
|
+
service: 'ls_foo',
|
|
488
|
+
query: {
|
|
489
|
+
test: 1
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
undefined
|
|
512
493
|
])
|
|
513
494
|
})
|
|
514
495
|
|