@meshconnect/uwc-bridge-child 0.2.2 → 0.2.3-snapshot.35caafb

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.
@@ -3,6 +3,12 @@
3
3
 
4
4
  import * as Comlink from 'comlink'
5
5
  import { hasInFrameTonBridge } from './tonInFrameBridge'
6
+ import {
7
+ MAX_POLL_MS,
8
+ POLL_INTERVAL_MS,
9
+ raceParentRead,
10
+ BridgeReadTimeout
11
+ } from './bridge-timing'
6
12
 
7
13
  // Define the expected parent API interface locally
8
14
  interface ParentAPI {
@@ -23,8 +29,12 @@ export class BridgeChild {
23
29
  private parentAPI?: Comlink.Remote<ParentAPI>
24
30
  private tronInjectedIds: string[]
25
31
  private tonJsBridgeKeys: string[]
26
- private tronDiscoveryTriggered = false
27
- private tonDiscoveryTriggered = false
32
+
33
+ // Per-namespace completion flags so we only marshal each set once.
34
+ private eip6963Done = false
35
+ private solanaDone = false
36
+ private tronDone = false
37
+ private tonDone = false
28
38
 
29
39
  constructor(options?: {
30
40
  tronInjectedIds?: string[]
@@ -41,241 +51,355 @@ export class BridgeChild {
41
51
  }
42
52
  window.UWCBridgeChildInitialized = true
43
53
 
54
+ // If another bootstrap already published at least one non-empty wallet
55
+ // array, skip everything. Matches the old BridgeChild semantics for
56
+ // embedded flows. Note: a previously-set empty array (`[]`, distinct from
57
+ // `undefined`) does NOT short-circuit us — another module saying "I
58
+ // couldn't find any EVM wallets" shouldn't prevent us from still
59
+ // discovering TON/Tron via the bridge.
60
+ if (this.anyWindowWalletsAlreadySet()) {
61
+ return
62
+ }
63
+
44
64
  this.parentAPI = Comlink.wrap<ParentAPI>(
45
65
  Comlink.windowEndpoint(window.parent, self, '*')
46
66
  )
47
67
 
68
+ // Tron/TON discovery is driven from the poll loop (tryMarshalTron/Ton),
69
+ // which re-issues discoverTron/TonWallets every tick until the parent
70
+ // answers. A single eager trigger here would be silently lost if it raced
71
+ // the parent's Comlink.expose (Comlink calls hang, never reject).
72
+
73
+ // Fetch the parent origin concurrently for TON Connect manifest URL.
74
+ this.syncParentOrigin()
75
+
48
76
  this.pollForWallets()
49
77
  }
50
78
 
51
- private async pollForWallets(): Promise<void> {
52
- const pollInterval = 100
53
- const maxPollTime = 10000 // Poll for maximum 10 seconds
54
- const startTime = Date.now()
79
+ private anyWindowWalletsAlreadySet(): boolean {
80
+ return Boolean(
81
+ (window.eip6963Wallets && window.eip6963Wallets.length > 0) ||
82
+ (window.walletStandardWallets &&
83
+ window.walletStandardWallets.length > 0) ||
84
+ (window.tronWallets && window.tronWallets.length > 0) ||
85
+ (window.tonWallets && window.tonWallets.length > 0)
86
+ )
87
+ }
55
88
 
56
- const poll = () => {
57
- // Check if we've exceeded the max poll time
58
- if (Date.now() - startTime > maxPollTime) {
89
+ private async syncParentOrigin(): Promise<void> {
90
+ // Same late-expose hazard as the wallet poll: a parentOrigin read posted
91
+ // before the parent's Comlink.expose hangs forever (the request message is
92
+ // dropped, so it never resolves even once the parent comes up). Retry with a
93
+ // per-read deadline until the parent answers or the poll budget elapses.
94
+ // A *resolved* read is final, not retried: the current BridgeParent sets
95
+ // parentOrigin to window.location.origin synchronously before expose, so a
96
+ // resolved value is always a real origin; a falsy resolve means an older
97
+ // parent without parentOrigin, where spinning the full budget is pointless.
98
+ const startTime = Date.now()
99
+ while (Date.now() - startTime <= MAX_POLL_MS) {
100
+ try {
101
+ const origin = await raceParentRead(this.parentAPI?.parentOrigin)
102
+ if (origin) window.uwcParentOrigin = origin
103
+ return
104
+ } catch (err) {
105
+ if (err instanceof BridgeReadTimeout) {
106
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
107
+ continue
108
+ }
109
+ // Older BridgeParent that throws on parentOrigin — ignore.
59
110
  return
60
111
  }
112
+ }
113
+ }
61
114
 
62
- // Check if global variables are already set
63
- if (
64
- (window.eip6963Wallets && window.eip6963Wallets.length > 0) ||
65
- (window.walletStandardWallets &&
66
- window.walletStandardWallets.length > 0) ||
67
- (window.tronWallets && window.tronWallets.length > 0) ||
68
- (window.tonWallets && window.tonWallets.length > 0)
69
- ) {
115
+ private pollForWallets(): void {
116
+ const startTime = Date.now()
117
+
118
+ const poll = async () => {
119
+ if (!this.parentAPI) return
120
+ if (Date.now() - startTime > MAX_POLL_MS) {
121
+ this.fillEmptyDefaults()
70
122
  return
71
123
  }
72
124
 
73
- if (!this.parentAPI) {
74
- setTimeout(poll, pollInterval)
125
+ // Run all four namespace marshals concurrently. Each short-circuits if
126
+ // already done or not yet ready, so the cost is a single
127
+ // `await ready` round-trip per pending namespace per tick.
128
+ await Promise.all([
129
+ this.tryMarshalEip6963(),
130
+ this.tryMarshalSolana(),
131
+ this.tryMarshalTron(),
132
+ this.tryMarshalTon()
133
+ ])
134
+
135
+ // Tron/TON have no early default: tryMarshal{Tron,Ton} writes the real
136
+ // wallets (or [] on a ready-with-empty / unsupported parent), and the
137
+ // MAX_POLL_MS fillEmptyDefaults backstop covers a parent that never
138
+ // reports ready. Writing [] earlier would race a still-pending discovery
139
+ // and make the consumer's one-shot poll miss a wallet that lands a tick
140
+ // later.
141
+
142
+ if (
143
+ this.eip6963Done &&
144
+ this.solanaDone &&
145
+ this.tronDone &&
146
+ this.tonDone
147
+ ) {
75
148
  return
76
149
  }
77
-
78
- // Create a timeout promise to prevent hanging
79
- const timeoutPromise = new Promise((_, reject) => {
80
- setTimeout(() => reject(new Error('Timeout accessing parent API')), 100)
81
- })
82
-
83
- // Try to check wallets with a timeout
84
- Promise.race([this.checkWallets(), timeoutPromise])
85
- .then(result => {
86
- if (result === 'continue') {
87
- // Continue polling
88
- setTimeout(poll, pollInterval)
89
- }
90
- // If result is 'stop', polling will stop
91
- })
92
- .catch(_error => {
93
- // Continue polling on error
94
- setTimeout(poll, pollInterval)
95
- })
150
+ setTimeout(poll, POLL_INTERVAL_MS)
96
151
  }
97
152
 
98
- // Start polling immediately
153
+ // Kick off synchronously — no first-tick delay.
99
154
  poll()
100
155
  }
101
156
 
102
- private async checkWallets(): Promise<string> {
157
+ private fillEmptyDefaults(): void {
158
+ if (window.eip6963Wallets === undefined) window.eip6963Wallets = []
159
+ if (window.walletStandardWallets === undefined) {
160
+ window.walletStandardWallets = []
161
+ }
162
+ if (window.tronWallets === undefined) window.tronWallets = []
163
+ if (window.tonWallets === undefined) window.tonWallets = []
164
+ }
165
+
166
+ private async tryMarshalEip6963(): Promise<void> {
167
+ if (this.eip6963Done || !this.parentAPI) return
103
168
  try {
104
- // Check if EIP6963 and Wallet Standard wallets are ready
105
- const eip6963Ready = await this.parentAPI?.eip6963WalletsReady
106
- const walletStandardReady =
107
- await this.parentAPI?.walletStandardWalletsReady
169
+ const ready = await raceParentRead(this.parentAPI.eip6963WalletsReady)
170
+ if (!ready) return
171
+
172
+ const len = await this.parentAPI.eip6963Wallets.length
173
+ const wallets: unknown[] = []
174
+ if (len > 0) {
175
+ const indices = Array.from({ length: len }, (_, i) => i)
176
+ const marshalled = await Promise.all(
177
+ indices.map(async i => {
178
+ const entry = this.parentAPI!.eip6963Wallets[i]
179
+ const [uuid, name, icon, rdns] = await Promise.all([
180
+ entry.uuid,
181
+ entry.name,
182
+ entry.icon,
183
+ entry.rdns
184
+ ])
185
+ return { uuid, name, icon, rdns, provider: entry.provider }
186
+ })
187
+ )
188
+ wallets.push(...marshalled)
189
+ }
190
+ window.eip6963Wallets = wallets
191
+ this.eip6963Done = true
192
+ } catch {
193
+ // retry on next tick
194
+ }
195
+ }
108
196
 
109
- if (!eip6963Ready || !walletStandardReady) {
110
- return 'continue'
197
+ private async tryMarshalSolana(): Promise<void> {
198
+ if (this.solanaDone || !this.parentAPI) return
199
+ try {
200
+ const ready = await raceParentRead(
201
+ this.parentAPI.walletStandardWalletsReady
202
+ )
203
+ if (!ready) return
204
+
205
+ const len = await this.parentAPI.walletStandardWallets.length
206
+ const wallets: unknown[] = []
207
+ if (len > 0) {
208
+ const indices = Array.from({ length: len }, (_, i) => i)
209
+ const marshalled = await Promise.all(
210
+ indices.map(async i => {
211
+ const entry = this.parentAPI!.walletStandardWallets[i]
212
+ const [uuid, name, chains, features] = await Promise.all([
213
+ entry.uuid,
214
+ entry.name,
215
+ entry.chains,
216
+ entry.features
217
+ ])
218
+ return { uuid, name, chains, features, adapter: entry.adapter }
219
+ })
220
+ )
221
+ wallets.push(...marshalled)
111
222
  }
223
+ window.walletStandardWallets = wallets
224
+ this.solanaDone = true
225
+ } catch {
226
+ // retry on next tick
227
+ }
228
+ }
112
229
 
113
- // Get EIP6963 wallets
114
- const availableEip6963Wallets =
115
- await this.parentAPI?.eip6963Wallets.length
116
- const eip6963Wallets = []
117
-
118
- if (availableEip6963Wallets > 0) {
119
- for (let index = 0; index < availableEip6963Wallets; index++) {
120
- const wallet = {
121
- uuid: await this.parentAPI.eip6963Wallets[index].uuid,
122
- name: await this.parentAPI.eip6963Wallets[index].name,
123
- icon: await this.parentAPI.eip6963Wallets[index].icon,
124
- rdns: await this.parentAPI.eip6963Wallets[index].rdns,
125
- provider: this.parentAPI.eip6963Wallets[index].provider
126
- }
127
- eip6963Wallets.push(wallet)
128
- }
230
+ private async tryMarshalTron(): Promise<void> {
231
+ if (this.tronDone || !this.parentAPI) return
232
+
233
+ // (Re-)ask the parent to run Tron discovery on every tick. A one-shot
234
+ // trigger is silently lost if posted before the parent's Comlink.expose (the
235
+ // request hangs, never rejects), so re-issuing mirrors EIP-6963's per-tick
236
+ // resilience. Comlink calls return a Promise and never throw synchronously,
237
+ // so an unsupported parent is handled by the tronWalletsReady read below (or
238
+ // the MAX_POLL_MS backstop) — not here. The try only guards a non-Comlink
239
+ // stub whose call throws synchronously.
240
+ // Each pre-expose reissue leaves one lightweight Comlink pending-listener
241
+ // Map entry (requestResponseMessage — not a DOM listener or MessageChannel
242
+ // port); bounded by the expose delay and GC'd. An in-flight guard is
243
+ // intentionally avoided: it would deadlock recovery, since a never-settling
244
+ // pre-expose call would block all subsequent re-issues.
245
+ //
246
+ // Fired in a detached async IIFE so that the await — needed to make the
247
+ // try/catch absorb both sync throws (test stubs) and async rejections —
248
+ // does not block this tick. A pre-expose hang sits inside the IIFE
249
+ // harmlessly until the parent comes up or GC reaps it.
250
+ void (async () => {
251
+ try {
252
+ await this.parentAPI.discoverTronWallets?.(this.tronInjectedIds)
253
+ } catch {
254
+ // absorb — the readiness read below governs the outcome
129
255
  }
256
+ })()
130
257
 
131
- // Get Wallet Standard wallets
132
- const availableWalletStandardWallets =
133
- await this.parentAPI.walletStandardWallets.length
134
- const walletStandardWallets = []
135
-
136
- if (availableWalletStandardWallets > 0) {
137
- for (let index = 0; index < availableWalletStandardWallets; index++) {
138
- const wallet = {
139
- uuid: await this.parentAPI.walletStandardWallets[index].uuid,
140
- name: await this.parentAPI.walletStandardWallets[index].name,
141
- chains: await this.parentAPI.walletStandardWallets[index].chains,
142
- features:
143
- await this.parentAPI.walletStandardWallets[index].features,
144
- adapter: this.parentAPI.walletStandardWallets[index].adapter
145
- }
146
- walletStandardWallets.push(wallet)
147
- }
258
+ let ready
259
+ try {
260
+ ready = await raceParentRead(this.parentAPI.tronWalletsReady)
261
+ } catch (err) {
262
+ // Read timed out → parent not live yet, retry next tick. Any other error
263
+ // (older parent throwing on the property) → no Tron support.
264
+ if (err instanceof BridgeReadTimeout) {
265
+ return
148
266
  }
267
+ window.tronWallets = []
268
+ this.tronDone = true
269
+ return
270
+ }
149
271
 
150
- // Get Tron wallets - wrapped in try/catch for backward compatibility
151
- // with older BridgeParent versions that don't have Tron support
152
- let tronWallets: unknown[] = []
153
- try {
154
- if (!this.tronDiscoveryTriggered) {
155
- await this.parentAPI?.discoverTronWallets?.(this.tronInjectedIds)
156
- this.tronDiscoveryTriggered = true
157
- }
272
+ if (!ready) return
158
273
 
159
- const tronReady = await this.parentAPI?.tronWalletsReady
160
- if (tronReady) {
161
- const availableTronWallets = await this.parentAPI.tronWallets.length
162
-
163
- if (availableTronWallets > 0) {
164
- for (let index = 0; index < availableTronWallets; index++) {
165
- const wallet = {
166
- uuid: await this.parentAPI.tronWallets[index].uuid,
167
- name: await this.parentAPI.tronWallets[index].name,
168
- injectedId: await this.parentAPI.tronWallets[index].injectedId,
169
- provider: this.parentAPI.tronWallets[index].provider
170
- }
171
- tronWallets.push(wallet)
172
- }
173
- }
174
- }
175
- } catch {
176
- // Parent doesn't support Tron - use empty array
177
- tronWallets = []
274
+ try {
275
+ const len = await this.parentAPI.tronWallets.length
276
+ const wallets: unknown[] = []
277
+ if (len > 0) {
278
+ const indices = Array.from({ length: len }, (_, i) => i)
279
+ const marshalled = await Promise.all(
280
+ indices.map(async i => {
281
+ const entry = this.parentAPI!.tronWallets[i]
282
+ const [uuid, name, injectedId] = await Promise.all([
283
+ entry.uuid,
284
+ entry.name,
285
+ entry.injectedId
286
+ ])
287
+ return { uuid, name, injectedId, provider: entry.provider }
288
+ })
289
+ )
290
+ wallets.push(...marshalled)
178
291
  }
292
+ window.tronWallets = wallets
293
+ this.tronDone = true
294
+ } catch {
295
+ window.tronWallets = []
296
+ this.tronDone = true
297
+ }
298
+ }
179
299
 
180
- // Get TON wallets - wrapped in try/catch for backward compatibility
181
- // with older BridgeParent versions that don't have TON support
182
- let tonWallets: unknown[] = []
183
- try {
184
- if (!this.tonDiscoveryTriggered) {
185
- await this.parentAPI?.discoverTonWallets?.(this.tonJsBridgeKeys)
186
- this.tonDiscoveryTriggered = true
187
- }
300
+ private async tryMarshalTon(): Promise<void> {
301
+ if (this.tonDone || !this.parentAPI) return
188
302
 
189
- const tonReady = await this.parentAPI?.tonWalletsReady
190
- if (tonReady) {
191
- const availableTonWallets = await this.parentAPI.tonWallets.length
192
-
193
- if (availableTonWallets > 0) {
194
- for (let index = 0; index < availableTonWallets; index++) {
195
- const wallet = {
196
- uuid: await this.parentAPI.tonWallets[index].uuid,
197
- name: await this.parentAPI.tonWallets[index].name,
198
- icon: await this.parentAPI.tonWallets[index].icon,
199
- jsBridgeKey: await this.parentAPI.tonWallets[index].jsBridgeKey,
200
- provider: this.parentAPI.tonWallets[index].provider
201
- }
202
- tonWallets.push(wallet)
203
- }
204
- }
205
- }
303
+ // Re-issue discovery each tick — same expose-race resilience as Tron.
304
+ // See tryMarshalTron for the detached-IIFE rationale.
305
+ void (async () => {
306
+ try {
307
+ await this.parentAPI.discoverTonWallets?.(this.tonJsBridgeKeys)
206
308
  } catch {
207
- // Parent doesn't support TON - use empty array
208
- tonWallets = []
309
+ // absorb the readiness read below governs the outcome
209
310
  }
311
+ })()
210
312
 
211
- // Set parent origin on window for TON Connect manifest URL resolution
212
- try {
213
- const origin = await this.parentAPI?.parentOrigin
214
- if (origin) {
215
- window.uwcParentOrigin = origin
216
- }
217
- } catch {
218
- // Parent doesn't support parentOrigin - older BridgeParent version
313
+ let ready
314
+ try {
315
+ ready = await raceParentRead(this.parentAPI.tonWalletsReady)
316
+ } catch (err) {
317
+ if (err instanceof BridgeReadTimeout) return
318
+ window.tonWallets = []
319
+ this.tonDone = true
320
+ return
321
+ }
322
+ if (!ready) return
323
+
324
+ try {
325
+ const len = await this.parentAPI.tonWallets.length
326
+ const wallets: Array<{
327
+ uuid: string
328
+ name: string
329
+ icon: string
330
+ jsBridgeKey: string
331
+ provider: any
332
+ }> = []
333
+
334
+ if (len > 0) {
335
+ const indices = Array.from({ length: len }, (_, i) => i)
336
+ const marshalled = await Promise.all(
337
+ indices.map(async i => {
338
+ const entry = this.parentAPI!.tonWallets[i]
339
+ const [uuid, name, icon, jsBridgeKey] = await Promise.all([
340
+ entry.uuid,
341
+ entry.name,
342
+ entry.icon,
343
+ entry.jsBridgeKey
344
+ ])
345
+ return { uuid, name, icon, jsBridgeKey, provider: entry.provider }
346
+ })
347
+ )
348
+ wallets.push(...marshalled)
219
349
  }
220
350
 
221
- // Set all wallet types on window
222
- window.eip6963Wallets = eip6963Wallets
223
- window.walletStandardWallets = walletStandardWallets
224
- window.tronWallets = tronWallets
225
- window.tonWallets = tonWallets
226
-
227
- // Shim window[jsBridgeKey].tonconnect so @tonconnect/sdk finds the Comlink proxy.
228
- // We build a local adapter because:
229
- // - Static props (deviceInfo, walletInfo) must be sync, but Comlink returns Promises
230
- // - listen(callback) can't send functions via postMessage without Comlink.proxy()
231
- // - listen() must return a sync unsubscribe function, not a Promise
232
- for (const wallet of tonWallets as {
233
- jsBridgeKey?: string
234
- provider?: any
235
- }[]) {
236
- if (!wallet.jsBridgeKey || !wallet.provider) continue
237
-
238
- // keep a real in-frame provider so connect() attributes to the
239
- // iframe origin; shimming it would attribute to the parent, which the
240
- // wallet's manifest origin check rejects.
241
- if (hasInFrameTonBridge(wallet.jsBridgeKey)) continue
242
-
243
- const proxy = wallet.provider
244
- // Read static properties upfront (they were transferred as values by Comlink)
245
- const deviceInfo = await proxy.deviceInfo
246
- const walletInfo = await proxy.walletInfo
247
- const protocolVersion = await proxy.protocolVersion
248
- const isWalletBrowser = await proxy.isWalletBrowser
249
-
250
- ;(window as any)[wallet.jsBridgeKey] = {
251
- tonconnect: {
252
- deviceInfo,
253
- walletInfo,
254
- protocolVersion: protocolVersion ?? 2,
255
- isWalletBrowser: isWalletBrowser ?? false,
256
- connect: (ver: number, msg: unknown) => proxy.connect(ver, msg),
257
- restoreConnection: () => proxy.restoreConnection(),
258
- send: (msg: unknown) => proxy.send(msg),
259
- listen: (callback: (event: unknown) => void) => {
260
- const proxiedCallback = Comlink.proxy(callback)
261
- let unsubscribe: (() => void) | null = null
262
- const result = proxy.listen(proxiedCallback)
263
- // Comlink returns a Promise; direct calls return the value
264
- Promise.resolve(result).then((unsub: unknown) => {
265
- unsubscribe = typeof unsub === 'function' ? unsub : () => {}
266
- })
267
- return () => {
268
- if (unsubscribe) unsubscribe()
269
- }
270
- },
271
- disconnect: () => proxy.disconnect?.()
351
+ window.tonWallets = wallets
352
+
353
+ // Shim window[jsBridgeKey].tonconnect so @tonconnect/sdk finds the
354
+ // Comlink proxy. Static props must be sync, but Comlink returns
355
+ // Promises — so we prefetch them here in parallel.
356
+ await Promise.all(
357
+ wallets.map(async wallet => {
358
+ if (!wallet.jsBridgeKey || !wallet.provider) return
359
+ // Keep the real in-frame provider so connect() attributes to the
360
+ // iframe origin; shimming would attribute to the parent, which the
361
+ // wallet's manifest origin check rejects.
362
+ if (hasInFrameTonBridge(wallet.jsBridgeKey)) return
363
+ const proxy = wallet.provider
364
+ const [deviceInfo, walletInfo, protocolVersion, isWalletBrowser] =
365
+ await Promise.all([
366
+ proxy.deviceInfo,
367
+ proxy.walletInfo,
368
+ proxy.protocolVersion,
369
+ proxy.isWalletBrowser
370
+ ])
371
+
372
+ ;(window as any)[wallet.jsBridgeKey] = {
373
+ tonconnect: {
374
+ deviceInfo,
375
+ walletInfo,
376
+ protocolVersion: protocolVersion ?? 2,
377
+ isWalletBrowser: isWalletBrowser ?? false,
378
+ connect: (ver: number, msg: unknown) => proxy.connect(ver, msg),
379
+ restoreConnection: () => proxy.restoreConnection(),
380
+ send: (msg: unknown) => proxy.send(msg),
381
+ listen: (callback: (event: unknown) => void) => {
382
+ const proxiedCallback = Comlink.proxy(callback)
383
+ let unsubscribe: (() => void) | null = null
384
+ const result = proxy.listen(proxiedCallback)
385
+ Promise.resolve(result).then((unsub: unknown) => {
386
+ unsubscribe = typeof unsub === 'function' ? unsub : () => {}
387
+ })
388
+ return () => {
389
+ if (unsubscribe) unsubscribe()
390
+ }
391
+ },
392
+ disconnect: () => proxy.disconnect?.()
393
+ }
272
394
  }
273
- }
274
- }
395
+ })
396
+ )
275
397
 
276
- return 'stop'
398
+ this.tonDone = true
277
399
  } catch {
278
- return 'continue'
400
+ // Parent doesn't support TON — mark done with empty list.
401
+ window.tonWallets = []
402
+ this.tonDone = true
279
403
  }
280
404
  }
281
405
  }
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { raceParentRead, BridgeReadTimeout } from './bridge-timing'
3
+
4
+ describe('raceParentRead', () => {
5
+ beforeEach(() => vi.useFakeTimers())
6
+ afterEach(() => vi.useRealTimers())
7
+
8
+ it('resolves with the value when the read settles before the deadline', async () => {
9
+ const result = await raceParentRead(Promise.resolve('origin'))
10
+ expect(result).toBe('origin')
11
+ })
12
+
13
+ it('resolves a non-promise value', async () => {
14
+ const result = await raceParentRead(true)
15
+ expect(result).toBe(true)
16
+ })
17
+
18
+ it('rejects with BridgeReadTimeout when the read never settles', async () => {
19
+ const neverSettles = new Promise<string>(() => {})
20
+ const assertion = expect(
21
+ raceParentRead(neverSettles)
22
+ ).rejects.toBeInstanceOf(BridgeReadTimeout)
23
+ await vi.advanceTimersByTimeAsync(200)
24
+ await assertion
25
+ })
26
+
27
+ it('clears the deadline timer on the winning-read path (no leaked timer)', async () => {
28
+ const clearSpy = vi.spyOn(globalThis, 'clearTimeout')
29
+ await raceParentRead(Promise.resolve('x'))
30
+ expect(clearSpy).toHaveBeenCalled()
31
+ clearSpy.mockRestore()
32
+ })
33
+ })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Timing primitives for the bridge child's wallet poll.
3
+ *
4
+ * Extracted into a typed module (BridgeChild.ts is `@ts-nocheck`) so this
5
+ * infrastructure is type-checked.
6
+ */
7
+
8
+ // Child's give-up deadline for the wallet poll: writes [] via fillEmptyDefaults
9
+ // if the parent never reports ready. The injected-connector consumer derives its
10
+ // own poll budget (BRIDGE_WALLET_POLL_MS) from this and MUST exceed it, so it can
11
+ // observe a wallet the child marshals on its final tick.
12
+ export const MAX_POLL_MS = 5000
13
+
14
+ export const POLL_INTERVAL_MS = 50
15
+
16
+ // A parent read is a postMessage round-trip — typically <5ms, so 150ms is
17
+ // generous. A spurious timeout (parent briefly busy) just retries on the next
18
+ // tick; it never drops a result.
19
+ const PARENT_READ_TIMEOUT_MS = 150
20
+
21
+ export class BridgeReadTimeout extends Error {
22
+ constructor() {
23
+ super('bridge parent read timed out')
24
+ this.name = 'BridgeReadTimeout'
25
+ }
26
+ }
27
+
28
+ /**
29
+ * A Comlink read to a parent that hasn't `Comlink.expose`d yet never settles
30
+ * (comlink's `requestResponseMessage` has no timeout, and the request message is
31
+ * dropped — so it won't resolve even once the parent comes up; a *new* read must
32
+ * be posted). The parent is typically created on the iframe's `load` event,
33
+ * which can fire after the child starts polling, so the first reads would hang
34
+ * the poll loop forever. Race every read against a short deadline so a pre-expose
35
+ * tick rejects (and the caller retries) instead of stalling.
36
+ */
37
+ export function raceParentRead<T>(
38
+ read: PromiseLike<T> | T
39
+ ): Promise<Awaited<T>> {
40
+ let timer: ReturnType<typeof setTimeout> | undefined
41
+ const timeout = new Promise<never>((_, reject) => {
42
+ timer = setTimeout(
43
+ () => reject(new BridgeReadTimeout()),
44
+ PARENT_READ_TIMEOUT_MS
45
+ )
46
+ })
47
+ // clearTimeout on settle so a winning read doesn't leak a pending timer. (The
48
+ // losing Comlink read can't be cancelled, but the timer can.)
49
+ return Promise.race([Promise.resolve(read), timeout]).finally(() =>
50
+ clearTimeout(timer)
51
+ )
52
+ }