@bsv/sdk 2.1.1 → 2.1.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.
Files changed (79) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/Peer.js +21 -18
  3. package/dist/cjs/src/auth/Peer.js.map +1 -1
  4. package/dist/cjs/src/auth/SessionManager.js.map +1 -1
  5. package/dist/cjs/src/auth/clients/AuthFetch.js +4 -1
  6. package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
  7. package/dist/cjs/src/identity/ContactsManager.js +44 -6
  8. package/dist/cjs/src/identity/ContactsManager.js.map +1 -1
  9. package/dist/cjs/src/identity/IdentityClient.js +106 -37
  10. package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
  11. package/dist/cjs/src/overlay-tools/LookupResolver.js +180 -82
  12. package/dist/cjs/src/overlay-tools/LookupResolver.js.map +1 -1
  13. package/dist/cjs/src/primitives/Hash.js +173 -50
  14. package/dist/cjs/src/primitives/Hash.js.map +1 -1
  15. package/dist/cjs/src/primitives/SymmetricKey.js +123 -1
  16. package/dist/cjs/src/primitives/SymmetricKey.js.map +1 -1
  17. package/dist/cjs/src/transaction/MerklePath.js +1 -1
  18. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  19. package/dist/esm/src/auth/Peer.js +28 -18
  20. package/dist/esm/src/auth/Peer.js.map +1 -1
  21. package/dist/esm/src/auth/SessionManager.js.map +1 -1
  22. package/dist/esm/src/auth/clients/AuthFetch.js +4 -1
  23. package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
  24. package/dist/esm/src/identity/ContactsManager.js +44 -6
  25. package/dist/esm/src/identity/ContactsManager.js.map +1 -1
  26. package/dist/esm/src/identity/IdentityClient.js +106 -37
  27. package/dist/esm/src/identity/IdentityClient.js.map +1 -1
  28. package/dist/esm/src/overlay-tools/LookupResolver.js +180 -82
  29. package/dist/esm/src/overlay-tools/LookupResolver.js.map +1 -1
  30. package/dist/esm/src/primitives/Hash.js +177 -50
  31. package/dist/esm/src/primitives/Hash.js.map +1 -1
  32. package/dist/esm/src/primitives/SymmetricKey.js +123 -1
  33. package/dist/esm/src/primitives/SymmetricKey.js.map +1 -1
  34. package/dist/esm/src/transaction/MerklePath.js +1 -1
  35. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  36. package/dist/types/src/auth/Peer.d.ts +3 -3
  37. package/dist/types/src/auth/Peer.d.ts.map +1 -1
  38. package/dist/types/src/auth/SessionManager.d.ts +21 -0
  39. package/dist/types/src/auth/SessionManager.d.ts.map +1 -1
  40. package/dist/types/src/auth/clients/AuthFetch.d.ts +2 -2
  41. package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
  42. package/dist/types/src/identity/ContactsManager.d.ts +13 -2
  43. package/dist/types/src/identity/ContactsManager.d.ts.map +1 -1
  44. package/dist/types/src/identity/IdentityClient.d.ts +50 -24
  45. package/dist/types/src/identity/IdentityClient.d.ts.map +1 -1
  46. package/dist/types/src/overlay-tools/LookupResolver.d.ts +15 -1
  47. package/dist/types/src/overlay-tools/LookupResolver.d.ts.map +1 -1
  48. package/dist/types/src/primitives/Hash.d.ts +21 -16
  49. package/dist/types/src/primitives/Hash.d.ts.map +1 -1
  50. package/dist/types/src/primitives/SymmetricKey.d.ts.map +1 -1
  51. package/dist/types/src/wallet/Wallet.interfaces.d.ts +16 -1
  52. package/dist/types/src/wallet/Wallet.interfaces.d.ts.map +1 -1
  53. package/dist/types/src/wallet/WalletClient.d.ts +1 -1
  54. package/dist/types/src/wallet/WalletClient.d.ts.map +1 -1
  55. package/dist/types/src/wallet/substrates/window.CWI.d.ts +1 -1
  56. package/dist/types/src/wallet/substrates/window.CWI.d.ts.map +1 -1
  57. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  58. package/dist/umd/bundle.js +4 -4
  59. package/package.json +1 -1
  60. package/src/auth/Peer.ts +30 -20
  61. package/src/auth/SessionManager.ts +22 -0
  62. package/src/auth/__tests/Peer.test.ts +47 -1
  63. package/src/auth/clients/AuthFetch.ts +6 -3
  64. package/src/identity/ContactsManager.ts +47 -6
  65. package/src/identity/IdentityClient.ts +137 -53
  66. package/src/identity/__tests/IdentityClient.additional.test.ts +150 -1
  67. package/src/identity/__tests/IdentityClient.test.ts +4 -4
  68. package/src/overlay-tools/LookupResolver.ts +191 -77
  69. package/src/overlay-tools/__tests/LookupResolver.additional.test.ts +90 -0
  70. package/src/primitives/Hash.ts +232 -96
  71. package/src/primitives/SymmetricKey.ts +145 -1
  72. package/src/primitives/__tests/Hash.additional.test.ts +65 -0
  73. package/src/primitives/__tests/Hash.test.ts +6 -1
  74. package/src/script/__tests/Spend.test.ts +45 -4
  75. package/src/transaction/MerklePath.ts +1 -1
  76. package/src/transaction/__tests/Transaction.test.ts +17 -0
  77. package/src/wallet/Wallet.interfaces.ts +16 -1
  78. package/src/wallet/WalletClient.ts +1 -1
  79. package/src/wallet/substrates/window.CWI.ts +1 -1
@@ -1,4 +1,5 @@
1
1
  import { Transaction } from '../transaction/index.js'
2
+ import { Beef } from '../transaction/Beef.js'
2
3
  import OverlayAdminTokenTemplate from './OverlayAdminTokenTemplate.js'
3
4
  import * as Utils from '../primitives/utils.js'
4
5
  import { getOverlayHostReputationTracker, HostReputationTracker } from './HostReputationTracker.js'
@@ -35,6 +36,8 @@ export type LookupAnswer =
35
36
  beef: number[]
36
37
  outputIndex: number
37
38
  context?: number[]
39
+ /** Optional txid hint. When present, consumers can skip re-parsing beef to derive the txid. */
40
+ txid?: string
38
41
  }>
39
42
  }
40
43
 
@@ -65,7 +68,7 @@ export interface LookupQueryOptions {
65
68
  */
66
69
  export interface LookupAnswerProgress {
67
70
  type: 'output-list'
68
- outputs: Array<{ beef: number[], outputIndex: number, context?: number[] }>
71
+ outputs: Array<{ beef: number[], outputIndex: number, context?: number[], txid?: string }>
69
72
  /** Parallel array of resolved tx ids for each output (same index as `outputs`). */
70
73
  txIds: string[]
71
74
  /** True only for the final emission, after every in-flight host has settled. */
@@ -102,6 +105,73 @@ export const DEFAULT_TESTNET_SLAP_TRACKERS: string[] = [
102
105
 
103
106
  const MAX_TRACKER_WAIT_TIME = 5000
104
107
 
108
+ /** A wall-clock deadline that rejects after `timeoutMs`, optionally aborting a controller. */
109
+ interface Deadline {
110
+ /** Rejects with `Error('Request timed out')` once the timer fires. */
111
+ promise: Promise<never>
112
+ /** Clears the underlying timer. Safe to call after the timer has already fired. */
113
+ cancel: () => void
114
+ /** Returns true once the timer has fired. */
115
+ didTimeOut: () => boolean
116
+ }
117
+
118
+ function createDeadline (timeoutMs: number, controller?: AbortController): Deadline {
119
+ let expired = false
120
+ let timer: ReturnType<typeof setTimeout> | null = null
121
+ const promise = new Promise<never>((_, reject) => {
122
+ timer = setTimeout(() => {
123
+ expired = true
124
+ try { controller?.abort() } catch { /* noop */ }
125
+ reject(new Error('Request timed out'))
126
+ }, timeoutMs)
127
+ })
128
+ return {
129
+ promise,
130
+ cancel: () => {
131
+ if (timer !== null) clearTimeout(timer)
132
+ },
133
+ didTimeOut: () => expired
134
+ }
135
+ }
136
+
137
+ function normalizeLookupError (err: unknown, timedOut: boolean): Error {
138
+ if (timedOut) return new Error('Request timed out')
139
+ if ((err as { name?: string })?.name === 'AbortError') return new Error('Request timed out')
140
+ if (err instanceof Error) return err
141
+ return new Error(stringifyErrorValue(err))
142
+ }
143
+
144
+ /**
145
+ * Coerce a non-Error thrown value to a human-readable string without falling
146
+ * back to the default `'[object Object]'` for plain objects.
147
+ */
148
+ function stringifyErrorValue (value: unknown): string {
149
+ if (value === null) return 'null'
150
+ if (value === undefined) return 'undefined'
151
+ if (typeof value === 'string') return value
152
+ if (typeof value === 'number') return value.toString()
153
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
154
+ if (typeof value === 'bigint') return value.toString()
155
+ const message = (value as { message?: unknown }).message
156
+ if (typeof message === 'string' && message.length > 0) return message
157
+ try {
158
+ return JSON.stringify(value) ?? 'Unknown error'
159
+ } catch {
160
+ return 'Unknown error'
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Returns true when the given Content-Type header value represents
166
+ * `application/octet-stream`, ignoring case and any media-type parameters
167
+ * (e.g. `; charset=utf-8`).
168
+ */
169
+ function isOctetStream (contentType: string | null): boolean {
170
+ if (typeof contentType !== 'string') return false
171
+ const baseType = contentType.split(';', 1)[0].trim().toLowerCase()
172
+ return baseType === 'application/octet-stream'
173
+ }
174
+
105
175
  /** Internal cache options. Kept optional to preserve drop-in compatibility. */
106
176
  interface CacheOptions {
107
177
  /** How long (ms) a hosts entry is considered fresh. Default 5 minutes. */
@@ -178,62 +248,89 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator {
178
248
  }
179
249
 
180
250
  const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
181
- const timer = setTimeout(() => {
182
- try { controller?.abort() } catch { /* noop */ }
183
- }, timeout)
251
+ const deadline = createDeadline(timeout, controller)
252
+
253
+ // Hard wall-clock deadline: in some environments (e.g. browser/Electron CORS
254
+ // failures) the underlying fetch can stall without ever settling, and the
255
+ // AbortController signal alone is insufficient to make the returned promise
256
+ // resolve or reject. Race the fetch against a setTimeout-backed reject so
257
+ // the consumer-facing promise always settles within `timeout` ms.
258
+ const fetchPromise = this.performLookupRequest(url, question, controller?.signal)
259
+ // Swallow background rejection if the deadline wins first.
260
+ fetchPromise.catch(() => { /* noop */ })
184
261
 
185
262
  try {
186
- const fco: RequestInit = {
187
- method: 'POST',
188
- headers: {
189
- 'Content-Type': 'application/json',
190
- 'X-Aggregation': 'yes'
191
- },
192
- body: JSON.stringify({ service: question.service, query: question.query }),
193
- signal: controller?.signal
194
- }
195
- const response: Response = await this.fetchClient(`${url}/lookup`, fco)
196
-
197
- if (!response.ok) throw new Error(`Failed to facilitate lookup (HTTP ${response.status})`)
198
- if (response.headers.get('content-type') === 'application/octet-stream') {
199
- const payload = await response.arrayBuffer()
200
- const r = new Utils.Reader([...new Uint8Array(payload)])
201
- const nOutpoints = r.readVarIntNum()
202
- const outpoints: Array<{ txid: string, outputIndex: number, context?: number[] }> = []
203
- for (let i = 0; i < nOutpoints; i++) {
204
- const txid = Utils.toHex(r.read(32))
205
- const outputIndex = r.readVarIntNum()
206
- const contextLength = r.readVarIntNum()
207
- let context
208
- if (contextLength > 0) {
209
- context = r.read(contextLength)
210
- }
211
- outpoints.push({
212
- txid,
213
- outputIndex,
214
- context
215
- })
216
- }
217
- const beef = r.read()
218
- return {
219
- type: 'output-list',
220
- outputs: outpoints.map(x => ({
221
- outputIndex: x.outputIndex,
222
- context: x.context,
223
- beef: Transaction.fromBEEF(beef, x.txid).toBEEF()
224
- }))
225
- }
226
- } else {
227
- return await response.json()
228
- }
263
+ return await Promise.race([fetchPromise, deadline.promise])
229
264
  } catch (e) {
230
- // Normalize timeouts to a consistent error message
231
- if ((e as { name?: string })?.name === 'AbortError') throw new Error('Request timed out')
232
- throw e
265
+ throw normalizeLookupError(e, deadline.didTimeOut())
233
266
  } finally {
234
- clearTimeout(timer)
267
+ deadline.cancel()
235
268
  }
236
269
  }
270
+
271
+ private async performLookupRequest (
272
+ url: string,
273
+ question: LookupQuestion,
274
+ signal: AbortSignal | undefined
275
+ ): Promise<LookupAnswer> {
276
+ const fco: RequestInit = {
277
+ method: 'POST',
278
+ headers: {
279
+ 'Content-Type': 'application/json',
280
+ 'X-Aggregation': 'yes'
281
+ },
282
+ body: JSON.stringify({ service: question.service, query: question.query }),
283
+ signal
284
+ }
285
+ const response: Response = await this.fetchClient(`${url}/lookup`, fco)
286
+ if (!response.ok) throw new Error(`Failed to facilitate lookup (HTTP ${response.status})`)
287
+ if (isOctetStream(response.headers.get('content-type'))) {
288
+ return await this.parseOctetStreamLookup(response)
289
+ }
290
+ return await response.json()
291
+ }
292
+
293
+ /** Parse the aggregated octet-stream lookup response into an output-list LookupAnswer. */
294
+ private async parseOctetStreamLookup (response: Response): Promise<LookupAnswer> {
295
+ const payload = await response.arrayBuffer()
296
+ const r = new Utils.Reader([...new Uint8Array(payload)])
297
+ const nOutpoints = r.readVarIntNum()
298
+ const outpoints: Array<{ txid: string, outputIndex: number, context?: number[] }> = []
299
+ for (let i = 0; i < nOutpoints; i++) {
300
+ const txid = Utils.toHex(r.read(32))
301
+ const outputIndex = r.readVarIntNum()
302
+ const contextLength = r.readVarIntNum()
303
+ const context = contextLength > 0 ? r.read(contextLength) : undefined
304
+ outpoints.push({ txid, outputIndex, context })
305
+ }
306
+ const beef = r.read()
307
+ const beefObj = Beef.fromBinary(beef)
308
+ const outputs = await this.extractAtomicOutputs(outpoints, beefObj)
309
+ return { type: 'output-list', outputs }
310
+ }
311
+
312
+ /** Memoize per-txid atomic BEEF extraction, yielding to the event loop between outputs. */
313
+ private async extractAtomicOutputs (
314
+ outpoints: Array<{ txid: string, outputIndex: number, context?: number[] }>,
315
+ beefObj: Beef
316
+ ): Promise<Array<{ outputIndex: number, context?: number[], beef: number[], txid: string }>> {
317
+ const beefByTxid = new Map<string, number[]>()
318
+ const outputs: Array<{ outputIndex: number, context?: number[], beef: number[], txid: string }> = new Array(outpoints.length)
319
+ for (let idx = 0; idx < outpoints.length; idx++) {
320
+ const x = outpoints[idx]
321
+ let beefBytes = beefByTxid.get(x.txid)
322
+ if (beefBytes === undefined) {
323
+ beefBytes = beefObj.toBinaryAtomic(x.txid)
324
+ beefByTxid.set(x.txid, beefBytes)
325
+ }
326
+ outputs[idx] = { outputIndex: x.outputIndex, context: x.context, beef: beefBytes, txid: x.txid }
327
+ // Yield to event loop so UI animations and other JS don't starve.
328
+ if (idx > 0 && idx < outpoints.length - 1) {
329
+ await new Promise<void>((resolve) => setTimeout(resolve, 0))
330
+ }
331
+ }
332
+ return outputs
333
+ }
237
334
  }
238
335
 
239
336
  /**
@@ -296,13 +393,18 @@ export default class LookupResolver {
296
393
  timeout?: number,
297
394
  options?: LookupQueryOptions
298
395
  ): Promise<LookupAnswer> {
299
- let last: LookupAnswerProgress | null = null
300
396
  // Existing fast-but-narrow contract: return at the first cumulative emission
301
397
  // (the post-grace aggregate, or the final emission when every host settles
302
398
  // before the grace window). Callers wanting progressive enrichment use query$().
303
- for await (const partial of this.query$(question, timeout, options)) {
304
- last = partial
305
- break
399
+ // Take only the first emission, then explicitly close the iterator so the
400
+ // generator's `finally` block runs and clears any outstanding timers.
401
+ const iter = this.query$(question, timeout, options)[Symbol.asyncIterator]()
402
+ let last: LookupAnswerProgress | null = null
403
+ try {
404
+ const { value, done } = await iter.next()
405
+ if (done !== true && value != null) last = value
406
+ } finally {
407
+ await iter.return?.(undefined)
306
408
  }
307
409
  return {
308
410
  type: 'output-list',
@@ -401,31 +503,16 @@ export default class LookupResolver {
401
503
  let graceFired = false
402
504
  let emittedOnce = false
403
505
 
404
- const beefKey = (beef: number[] | undefined): string => {
405
- if (typeof beef !== 'object' || beef == null) return ''
406
- return beef.join(',')
407
- }
408
-
409
506
  const mergeAnswer = (answer: LookupAnswer): boolean => {
410
507
  let added = false
508
+ const now = Date.now()
411
509
  for (const output of answer.outputs) {
412
- const keyForBeef = beefKey(output.beef)
413
- let memo = this.txMemo.get(keyForBeef)
414
- const now = Date.now()
415
- if (typeof memo !== 'object' || memo === null || memo.expiresAt <= now) {
416
- try {
417
- const txId = Transaction.fromBEEF(output.beef).id('hex')
418
- memo = { txId, expiresAt: now + this.txMemoTtlMs }
419
- if (this.txMemo.size > 4096) this.evictOldest(this.txMemo)
420
- this.txMemo.set(keyForBeef, memo)
421
- } catch {
422
- continue
423
- }
424
- }
425
- const uniqKey = `${memo.txId}.${output.outputIndex}`
510
+ const txId = this.resolveTxIdForOutput(output, now)
511
+ if (txId === null) continue
512
+ const uniqKey = `${txId}.${output.outputIndex}`
426
513
  if (!outputsMap.has(uniqKey)) {
427
514
  outputsMap.set(uniqKey, output)
428
- txIds.push(memo.txId)
515
+ txIds.push(txId)
429
516
  added = true
430
517
  }
431
518
  }
@@ -603,7 +690,7 @@ export default class LookupResolver {
603
690
  resolve([...allHosts])
604
691
  }
605
692
  })
606
- .catch(() => { /* tracker failed; tracked by lookupHostWithTracking */ })
693
+ .catch(() => { /* tracker failure tracked in reputation */ })
607
694
  .finally(() => {
608
695
  pending--
609
696
  if (pending === 0 && !resolved) {
@@ -615,7 +702,34 @@ export default class LookupResolver {
615
702
  })
616
703
  }
617
704
 
618
- /** Evict an arbitrary “oldest” entry from a Map (iteration order). */
705
+ /**
706
+ * Resolve a txid for an aggregated lookup output. Uses the threaded-through `output.txid`
707
+ * fast path when present; otherwise memoizes Transaction.fromBEEF(beef).id('hex') keyed by
708
+ * the BEEF byte sequence. Returns null when the BEEF is unparseable.
709
+ */
710
+ private resolveTxIdForOutput (
711
+ output: { txid?: string, beef: number[], outputIndex: number, context?: number[] },
712
+ now: number
713
+ ): string | null {
714
+ if (typeof output.txid === 'string' && output.txid.length > 0) {
715
+ return output.txid
716
+ }
717
+ const keyForBeef = Array.isArray(output.beef) ? output.beef.join(',') : ''
718
+ const memo = this.txMemo.get(keyForBeef)
719
+ if (typeof memo === 'object' && memo !== null && memo.expiresAt > now) {
720
+ return memo.txId
721
+ }
722
+ try {
723
+ const txId = Transaction.fromBEEF(output.beef).id('hex')
724
+ if (this.txMemo.size > 4096) this.evictOldest(this.txMemo)
725
+ this.txMemo.set(keyForBeef, { txId, expiresAt: now + this.txMemoTtlMs })
726
+ return txId
727
+ } catch {
728
+ return null
729
+ }
730
+ }
731
+
732
+ /** Evict an arbitrary "oldest" entry from a Map (iteration order). */
619
733
  private evictOldest<T>(m: Map<string, T>): void {
620
734
  const firstKey = m.keys().next().value
621
735
  if (firstKey !== undefined) m.delete(firstKey)
@@ -472,6 +472,22 @@ describe('LookupResolver – additional coverage', () => {
472
472
  ).rejects.toThrow('Request timed out')
473
473
  })
474
474
 
475
+ it('rejects within the timeout even when fetch never settles', async () => {
476
+ // Simulate the CORS-blocked / hung-preflight case where the fetch promise
477
+ // does not honor the AbortController signal and never settles.
478
+ const neverFetch = jest.fn().mockImplementation(
479
+ () => new Promise(() => { /* never resolves */ })
480
+ )
481
+ const facilitator = new HTTPSOverlayLookupFacilitator(neverFetch, true)
482
+ const start = Date.now()
483
+ await expect(
484
+ facilitator.lookup('http://host', { service: 'ls_test', query: {} }, 50)
485
+ ).rejects.toThrow('Request timed out')
486
+ const elapsed = Date.now() - start
487
+ // Allow generous slack but must complete well before any global jest timeout.
488
+ expect(elapsed).toBeLessThan(2000)
489
+ })
490
+
475
491
  it('parses octet-stream responses', async () => {
476
492
  // Build a minimal octet-stream payload: 1 outpoint, then BEEF bytes
477
493
  const tx = new Transaction(
@@ -506,6 +522,36 @@ describe('LookupResolver – additional coverage', () => {
506
522
  expect(result.outputs[0].outputIndex).toBe(0)
507
523
  })
508
524
 
525
+ it('parses octet-stream responses when header carries parameters or differing case', async () => {
526
+ const tx = new Transaction(
527
+ 1,
528
+ [],
529
+ [{ lockingScript: LockingScript.fromHex('88'), satoshis: 1 }],
530
+ 0
531
+ )
532
+ const beef = tx.toBEEF()
533
+ const txid = Buffer.from(tx.id('hex'), 'hex')
534
+ const payload = Buffer.concat([
535
+ Buffer.from([0x01]), txid, Buffer.from([0x00]), Buffer.from([0x00]), Buffer.from(beef)
536
+ ])
537
+
538
+ for (const header of [
539
+ 'application/octet-stream; charset=utf-8',
540
+ 'Application/Octet-Stream',
541
+ ' application/octet-stream '
542
+ ]) {
543
+ const mockFetch = jest.fn().mockResolvedValue({
544
+ ok: true,
545
+ headers: { get: () => header },
546
+ arrayBuffer: async () => payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength)
547
+ })
548
+ const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
549
+ const result = await facilitator.lookup('https://host', { service: 'ls_test', query: {} })
550
+ expect(result.type).toBe('output-list')
551
+ expect(result.outputs).toHaveLength(1)
552
+ }
553
+ })
554
+
509
555
  it('parses octet-stream responses with context bytes', async () => {
510
556
  const tx = new Transaction(
511
557
  1,
@@ -547,6 +593,50 @@ describe('LookupResolver – additional coverage', () => {
547
593
  ).rejects.toThrow('DNS failure')
548
594
  })
549
595
 
596
+ it('normalises string thrown values from fetch', async () => {
597
+ const mockFetch = jest.fn().mockRejectedValue('boom')
598
+ const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
599
+ await expect(
600
+ facilitator.lookup('https://host', { service: 'ls_test', query: {} })
601
+ ).rejects.toThrow('boom')
602
+ })
603
+
604
+ it('normalises object-with-message thrown values from fetch', async () => {
605
+ const mockFetch = jest.fn().mockRejectedValue({ message: 'object boom' })
606
+ const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
607
+ await expect(
608
+ facilitator.lookup('https://host', { service: 'ls_test', query: {} })
609
+ ).rejects.toThrow('object boom')
610
+ })
611
+
612
+ it('normalises plain-object thrown values via JSON', async () => {
613
+ const mockFetch = jest.fn().mockRejectedValue({ code: 42 })
614
+ const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
615
+ await expect(
616
+ facilitator.lookup('https://host', { service: 'ls_test', query: {} })
617
+ ).rejects.toThrow('{"code":42}')
618
+ })
619
+
620
+ it('normalises number/boolean/null thrown values from fetch', async () => {
621
+ for (const value of [123, true, null]) {
622
+ const mockFetch = jest.fn().mockRejectedValue(value)
623
+ const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
624
+ await expect(
625
+ facilitator.lookup('https://host', { service: 'ls_test', query: {} })
626
+ ).rejects.toThrow(String(value))
627
+ }
628
+ })
629
+
630
+ it('normalises circular thrown values without crashing', async () => {
631
+ const circular: { self?: unknown } = {}
632
+ circular.self = circular
633
+ const mockFetch = jest.fn().mockRejectedValue(circular)
634
+ const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
635
+ await expect(
636
+ facilitator.lookup('https://host', { service: 'ls_test', query: {} })
637
+ ).rejects.toThrow('Unknown error')
638
+ })
639
+
550
640
  it('sends correct request body to /lookup endpoint', async () => {
551
641
  const mockFetch = jest.fn().mockResolvedValue({
552
642
  ok: true,