@meshconnect/uwc-core 1.0.3 → 1.1.0

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 (121) hide show
  1. package/README.md +247 -76
  2. package/dist/events.d.ts +241 -5
  3. package/dist/events.d.ts.map +1 -1
  4. package/dist/events.js +77 -1
  5. package/dist/events.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/logger/create-logger.d.ts +32 -0
  11. package/dist/logger/create-logger.d.ts.map +1 -0
  12. package/dist/logger/create-logger.js +79 -0
  13. package/dist/logger/create-logger.js.map +1 -0
  14. package/dist/managers/event-manager.d.ts.map +1 -1
  15. package/dist/managers/event-manager.js +5 -1
  16. package/dist/managers/event-manager.js.map +1 -1
  17. package/dist/observability/connect-observer.d.ts +33 -0
  18. package/dist/observability/connect-observer.d.ts.map +1 -0
  19. package/dist/observability/connect-observer.js +115 -0
  20. package/dist/observability/connect-observer.js.map +1 -0
  21. package/dist/observability/focus-tracker.d.ts +42 -0
  22. package/dist/observability/focus-tracker.d.ts.map +1 -0
  23. package/dist/observability/focus-tracker.js +57 -0
  24. package/dist/observability/focus-tracker.js.map +1 -0
  25. package/dist/observability/instrument-handoff.d.ts +54 -0
  26. package/dist/observability/instrument-handoff.d.ts.map +1 -0
  27. package/dist/observability/instrument-handoff.js +173 -0
  28. package/dist/observability/instrument-handoff.js.map +1 -0
  29. package/dist/observability/lifecycle-bridge.d.ts +69 -0
  30. package/dist/observability/lifecycle-bridge.d.ts.map +1 -0
  31. package/dist/observability/lifecycle-bridge.js +155 -0
  32. package/dist/observability/lifecycle-bridge.js.map +1 -0
  33. package/dist/observability/scrub.d.ts +31 -0
  34. package/dist/observability/scrub.d.ts.map +1 -0
  35. package/dist/observability/scrub.js +167 -0
  36. package/dist/observability/scrub.js.map +1 -0
  37. package/dist/observability/telemetry.d.ts +157 -0
  38. package/dist/observability/telemetry.d.ts.map +1 -0
  39. package/dist/observability/telemetry.js +373 -0
  40. package/dist/observability/telemetry.js.map +1 -0
  41. package/dist/services/connection-service.d.ts +23 -1
  42. package/dist/services/connection-service.d.ts.map +1 -1
  43. package/dist/services/connection-service.js +128 -19
  44. package/dist/services/connection-service.js.map +1 -1
  45. package/dist/services/network-switch-service.d.ts +2 -1
  46. package/dist/services/network-switch-service.d.ts.map +1 -1
  47. package/dist/services/network-switch-service.js +45 -16
  48. package/dist/services/network-switch-service.js.map +1 -1
  49. package/dist/services/signature-service.d.ts +14 -7
  50. package/dist/services/signature-service.d.ts.map +1 -1
  51. package/dist/services/signature-service.js +56 -58
  52. package/dist/services/signature-service.js.map +1 -1
  53. package/dist/services/transaction-service.d.ts +19 -5
  54. package/dist/services/transaction-service.d.ts.map +1 -1
  55. package/dist/services/transaction-service.js +55 -38
  56. package/dist/services/transaction-service.js.map +1 -1
  57. package/dist/universal-wallet-connector.d.ts +56 -1
  58. package/dist/universal-wallet-connector.d.ts.map +1 -1
  59. package/dist/universal-wallet-connector.js +207 -110
  60. package/dist/universal-wallet-connector.js.map +1 -1
  61. package/dist/utils/id.d.ts +2 -0
  62. package/dist/utils/id.d.ts.map +1 -0
  63. package/dist/utils/id.js +41 -0
  64. package/dist/utils/id.js.map +1 -0
  65. package/dist/utils/resolve-session-provider.d.ts +10 -0
  66. package/dist/utils/resolve-session-provider.d.ts.map +1 -0
  67. package/dist/utils/resolve-session-provider.js +21 -0
  68. package/dist/utils/resolve-session-provider.js.map +1 -0
  69. package/dist/utils/to-wallet-error.d.ts +35 -0
  70. package/dist/utils/to-wallet-error.d.ts.map +1 -0
  71. package/dist/utils/to-wallet-error.js +130 -0
  72. package/dist/utils/to-wallet-error.js.map +1 -0
  73. package/dist/utils/validate-wallet-session.d.ts +15 -0
  74. package/dist/utils/validate-wallet-session.d.ts.map +1 -0
  75. package/dist/utils/validate-wallet-session.js +29 -0
  76. package/dist/utils/validate-wallet-session.js.map +1 -0
  77. package/dist/version.d.ts +2 -0
  78. package/dist/version.d.ts.map +1 -0
  79. package/dist/version.js +3 -0
  80. package/dist/version.js.map +1 -0
  81. package/package.json +6 -6
  82. package/src/events.ts +326 -14
  83. package/src/index.ts +10 -0
  84. package/src/logger/create-logger.test.ts +126 -0
  85. package/src/logger/create-logger.ts +112 -0
  86. package/src/managers/event-manager.test.ts +55 -0
  87. package/src/managers/event-manager.ts +5 -1
  88. package/src/observability/connect-observer.test.ts +214 -0
  89. package/src/observability/connect-observer.ts +128 -0
  90. package/src/observability/focus-tracker.test.ts +191 -0
  91. package/src/observability/focus-tracker.ts +96 -0
  92. package/src/observability/instrument-handoff.test.ts +397 -0
  93. package/src/observability/instrument-handoff.ts +231 -0
  94. package/src/observability/lifecycle-bridge.test.ts +398 -0
  95. package/src/observability/lifecycle-bridge.ts +216 -0
  96. package/src/observability/scrub.test.ts +183 -0
  97. package/src/observability/scrub.ts +174 -0
  98. package/src/observability/telemetry.test.ts +549 -0
  99. package/src/observability/telemetry.ts +546 -0
  100. package/src/services/connection-service.test.ts +261 -1
  101. package/src/services/connection-service.ts +167 -38
  102. package/src/services/network-switch-service.test.ts +51 -1
  103. package/src/services/network-switch-service.ts +56 -24
  104. package/src/services/signature-service.test.ts +181 -76
  105. package/src/services/signature-service.ts +75 -85
  106. package/src/services/transaction-service.test.ts +270 -69
  107. package/src/services/transaction-service.ts +74 -51
  108. package/src/universal-wallet-connector.instrumentation-guard.test.ts +199 -0
  109. package/src/universal-wallet-connector.observability.test.ts +610 -0
  110. package/src/universal-wallet-connector.test.ts +27 -146
  111. package/src/universal-wallet-connector.ts +279 -142
  112. package/src/utils/id.test.ts +15 -0
  113. package/src/utils/id.ts +48 -0
  114. package/src/utils/resolve-session-provider.test.ts +54 -0
  115. package/src/utils/resolve-session-provider.ts +25 -0
  116. package/src/utils/to-wallet-error.test.ts +181 -0
  117. package/src/utils/to-wallet-error.ts +152 -0
  118. package/src/utils/validate-wallet-session.test.ts +77 -0
  119. package/src/utils/validate-wallet-session.ts +40 -0
  120. package/src/version.test.ts +11 -0
  121. package/src/version.ts +2 -0
@@ -1,4 +1,5 @@
1
1
  import type { UWCEventMap, UWCEventName, UWCEventListener } from '../events'
2
+ import { TELEMETRY_ONLY_EVENTS } from '../events'
2
3
 
3
4
  type LegacyListener = () => void
4
5
  type UntypedListener = (data: unknown) => void
@@ -46,7 +47,10 @@ export class EventManager {
46
47
 
47
48
  emit<K extends UWCEventName>(event: K, data: UWCEventMap[K]): void {
48
49
  this.dispatch(event, data)
49
- if (event !== 'change') {
50
+ // Telemetry-only events (logs, handoff instrumentation) describe what
51
+ // happened, not a state change — don't cascade them to `change`, or every
52
+ // log line would trigger a React re-render via legacy `subscribe()`.
53
+ if (event !== 'change' && !TELEMETRY_ONLY_EVENTS.has(event)) {
50
54
  this.dispatch('change', undefined as UWCEventMap['change'])
51
55
  }
52
56
  }
@@ -0,0 +1,214 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import type { Session } from '@meshconnect/uwc-types'
3
+ import { EventManager } from '../managers/event-manager'
4
+ import {
5
+ connectObserver,
6
+ MAX_REPORTED_OBSERVER_ERRORS
7
+ } from './connect-observer'
8
+ import type { UWCTelemetryEvent } from './telemetry'
9
+
10
+ const FIXED_NOW = 1_700_000_000_000
11
+
12
+ describe('connectObserver', () => {
13
+ it('forwards typed events to onEvent as normalized records', () => {
14
+ const em = new EventManager()
15
+ const events: UWCTelemetryEvent[] = []
16
+ connectObserver(
17
+ em,
18
+ { onEvent: e => events.push(e) },
19
+ 'sess',
20
+ undefined,
21
+ () => FIXED_NOW
22
+ )
23
+
24
+ em.emit('connecting', { connectionMode: 'injected', walletId: 'metamask' })
25
+
26
+ expect(events).toHaveLength(1)
27
+ expect(events[0]).toMatchObject({
28
+ name: 'connecting',
29
+ operation: 'connect',
30
+ walletId: 'metamask',
31
+ sdkSessionId: 'sess',
32
+ timestamp: FIXED_NOW
33
+ })
34
+ })
35
+
36
+ it('a throwing correlation-id getter degrades to "no id" — the record is still delivered', () => {
37
+ // A broken id source is a config problem, not an observer failure: the
38
+ // record must not be dropped (or misattributed as "observer threw").
39
+ const em = new EventManager()
40
+ const events: UWCTelemetryEvent[] = []
41
+ connectObserver(
42
+ em,
43
+ { onEvent: e => events.push(e) },
44
+ 'sess',
45
+ () => {
46
+ throw new Error('id source not ready')
47
+ },
48
+ () => FIXED_NOW
49
+ )
50
+
51
+ em.emit('connecting', { connectionMode: 'injected', walletId: 'metamask' })
52
+
53
+ expect(events).toHaveLength(1)
54
+ expect(events[0]?.correlationId).toBeUndefined()
55
+ })
56
+
57
+ it('routes log to onLog (scrubbed args), not onEvent', () => {
58
+ const em = new EventManager()
59
+ const onEvent = vi.fn()
60
+ const onLog = vi.fn()
61
+ connectObserver(em, { onEvent, onLog }, 'sess')
62
+
63
+ em.emit('log', {
64
+ level: 'info',
65
+ message: 'hi',
66
+ args: [{ secret: 's', id: 1 }]
67
+ })
68
+
69
+ expect(onEvent).not.toHaveBeenCalled()
70
+ expect(onLog).toHaveBeenCalledTimes(1)
71
+ const [level, message, args] = onLog.mock.calls[0]
72
+ expect(level).toBe('info')
73
+ expect(message).toBe('hi')
74
+ expect((args[0] as Record<string, unknown>).secret).toBe('[Redacted]')
75
+ expect((args[0] as Record<string, unknown>).id).toBe(1)
76
+ })
77
+
78
+ it('does not forward the legacy change event', () => {
79
+ const em = new EventManager()
80
+ const onEvent = vi.fn()
81
+ connectObserver(em, { onEvent }, 'sess')
82
+ em.emit('change', undefined)
83
+ expect(onEvent).not.toHaveBeenCalled()
84
+ })
85
+
86
+ it('teardown removes every subscription', () => {
87
+ const em = new EventManager()
88
+ const onEvent = vi.fn()
89
+ const teardown = connectObserver(em, { onEvent }, 'sess')
90
+ teardown()
91
+ em.emit('connecting', { connectionMode: 'injected', walletId: 'x' })
92
+ expect(onEvent).not.toHaveBeenCalled()
93
+ expect(em.getListenerCount()).toBe(0)
94
+ })
95
+
96
+ it('does not leak the wallet address through a connected event', () => {
97
+ const em = new EventManager()
98
+ const events: UWCTelemetryEvent[] = []
99
+ connectObserver(em, { onEvent: e => events.push(e) }, 'sess')
100
+ const session = {
101
+ connectionMode: 'injected',
102
+ activeNetwork: { namespace: 'eip155', id: 'eip155:1' },
103
+ activeWallet: { id: 'metamask' },
104
+ activeAddress: '0xSECRETADDR'
105
+ } as unknown as Session
106
+ em.emit('connected', { session })
107
+ expect(JSON.stringify(events)).not.toContain('0xSECRETADDR')
108
+ })
109
+
110
+ it('a throwing observer never breaks the dispatch chain', () => {
111
+ const em = new EventManager()
112
+ connectObserver(
113
+ em,
114
+ {
115
+ onEvent: () => {
116
+ throw new Error('observer boom')
117
+ }
118
+ },
119
+ 'sess'
120
+ )
121
+ expect(() =>
122
+ em.emit('connecting', { connectionMode: 'injected', walletId: 'x' })
123
+ ).not.toThrow()
124
+ })
125
+
126
+ it('stamps a consumer correlationId as its own field, sdkSessionId stays the instance id', () => {
127
+ const em = new EventManager()
128
+ const events: UWCTelemetryEvent[] = []
129
+ connectObserver(
130
+ em,
131
+ { onEvent: e => events.push(e) },
132
+ 'instance-sess',
133
+ () => 'link-123'
134
+ )
135
+ em.emit('connecting', { connectionMode: 'injected', walletId: 'metamask' })
136
+ expect(events[0].sdkSessionId).toBe('instance-sess')
137
+ expect(events[0].correlationId).toBe('link-123')
138
+ })
139
+
140
+ it('reports a throwing observer via logger.debug without recursing into onLog', () => {
141
+ const em = new EventManager()
142
+ const onLog = vi.fn()
143
+ // Mirror the production wiring: the real logger's pre-gate tap re-emits a
144
+ // `log` event on this same bus. Without the recursion guard, the failure
145
+ // notice would loop back into the (bad) observer's onLog.
146
+ const debug = vi.fn((message: string, ...args: unknown[]) =>
147
+ em.emit('log', { level: 'debug', message, args })
148
+ )
149
+ const logger = { debug, info: vi.fn(), warn: vi.fn(), error: vi.fn() }
150
+ connectObserver(
151
+ em,
152
+ {
153
+ onEvent: () => {
154
+ throw new Error('unique-observer-boom')
155
+ },
156
+ onLog
157
+ },
158
+ 'sess',
159
+ undefined,
160
+ Date.now,
161
+ logger
162
+ )
163
+
164
+ em.emit('connecting', { connectionMode: 'injected', walletId: 'x' })
165
+
166
+ // Reported once, via debug...
167
+ expect(debug).toHaveBeenCalledTimes(1)
168
+ expect(debug.mock.calls[0][0]).toBe('UWC observer threw')
169
+ // ...and the guard kept that debug log from re-entering the bad observer.
170
+ expect(onLog).not.toHaveBeenCalled()
171
+ })
172
+
173
+ it('bounds the observer-error dedup set — an evicted message can report again past the cap', () => {
174
+ // Same storm-guard contract as the relay/bridge dedup sets: dedup is not a
175
+ // permanent ledger. An observer throwing high-cardinality messages must not
176
+ // grow the set without bound over a long-lived instance.
177
+ const em = new EventManager()
178
+ const debug = vi.fn()
179
+ const logger = { debug, info: vi.fn(), warn: vi.fn(), error: vi.fn() }
180
+ let boom: string | undefined
181
+ connectObserver(
182
+ em,
183
+ {
184
+ onEvent: () => {
185
+ if (boom) throw new Error(boom)
186
+ }
187
+ },
188
+ 'sess',
189
+ undefined,
190
+ Date.now,
191
+ logger
192
+ )
193
+ const fire = (message: string) => {
194
+ boom = message
195
+ em.emit('connecting', { connectionMode: 'injected', walletId: 'x' })
196
+ }
197
+
198
+ // Fill the set to the cap with distinct messages — each reports once.
199
+ for (let i = 0; i < MAX_REPORTED_OBSERVER_ERRORS; i++) fire(`err-${i}`)
200
+ expect(debug).toHaveBeenCalledTimes(MAX_REPORTED_OBSERVER_ERRORS)
201
+
202
+ // Still deduped while in the set.
203
+ fire('err-0')
204
+ expect(debug).toHaveBeenCalledTimes(MAX_REPORTED_OBSERVER_ERRORS)
205
+
206
+ // One more distinct message trips the cap → the set is evicted → it reports.
207
+ fire('over-cap')
208
+ expect(debug).toHaveBeenCalledTimes(MAX_REPORTED_OBSERVER_ERRORS + 1)
209
+
210
+ // err-0 was evicted, so it reports again — proving the set stays bounded.
211
+ fire('err-0')
212
+ expect(debug).toHaveBeenCalledTimes(MAX_REPORTED_OBSERVER_ERRORS + 2)
213
+ })
214
+ })
@@ -0,0 +1,128 @@
1
+ import { createBoundedDedup, safeErrorMessage } from '@meshconnect/uwc-types'
2
+ import type { Logger } from '@meshconnect/uwc-types'
3
+ import type { EventManager } from '../managers/event-manager'
4
+ import { UWC_EVENT_NAMES } from '../events'
5
+ import type { UWCEventMap } from '../events'
6
+ import { normalizeEvent, type UWCObserver } from './telemetry'
7
+ import { scrubArgs, scrubMessage } from './scrub'
8
+
9
+ /**
10
+ * Bridge the internal event bus to a consumer-supplied `UWCObserver`.
11
+ *
12
+ * Subscribes to every `UWCEventMap` event, normalizes each to a PII-safe
13
+ * `UWCTelemetryEvent`, and forwards it to `observer.onEvent`. The `log` event is
14
+ * routed to `observer.onLog` instead (with scrubbed args) — it's the logging
15
+ * channel, not a telemetry record. `change` is skipped: it's the legacy
16
+ * re-render cascade and carries no payload.
17
+ *
18
+ * Returns a teardown function that removes every subscription. The caller MUST
19
+ * invoke it on connector teardown — leaving these attached is the same listener-
20
+ * leak class as the historical `BridgeParent.destroy()` zombie bug.
21
+ *
22
+ * @param sdkSessionId - per-connector-instance id stamped on every record.
23
+ * @param getCorrelationId - resolves the optional consumer correlation id,
24
+ * called per-event so an id that arrives/changes after construction is
25
+ * honoured. Must not throw (the caller pre-wraps it). Defaults to `undefined`.
26
+ * @param now - clock injectable for deterministic tests; defaults to `Date.now`.
27
+ * @param logger - optional; a throwing observer is reported via `logger.debug`.
28
+ * When omitted the throw is swallowed silently (the historical behaviour).
29
+ */
30
+ /**
31
+ * Cap on distinct observer-error messages remembered for dedup. Same storm-guard
32
+ * contract as the relay/bridge dedup sets: bounding keeps memory flat on a
33
+ * long-lived instance even when a broken observer throws high-cardinality
34
+ * messages (e.g. embedding event data or timestamps).
35
+ */
36
+ export const MAX_REPORTED_OBSERVER_ERRORS = 100
37
+
38
+ export function connectObserver(
39
+ eventManager: EventManager,
40
+ observer: UWCObserver,
41
+ sdkSessionId: string,
42
+ getCorrelationId: () => string | undefined = () => undefined,
43
+ now: () => number = Date.now,
44
+ logger?: Logger
45
+ ): () => void {
46
+ const unsubscribers: Array<() => void> = []
47
+
48
+ // Set while we log an observer failure so the resulting `logger.debug(...)` —
49
+ // which re-enters the bus as a `log` event — is NOT forwarded back to the same
50
+ // (throwing) observer, which would recurse. One logical dispatch at a time.
51
+ let loggingObserverError = false
52
+
53
+ // "Once per error site": dedupe by the error's message so a wallet whose
54
+ // observer throws on every event logs once, not on a tight loop. Scoped to THIS
55
+ // connector instance (not module-global) so one connector's failures don't
56
+ // suppress another's; bounded so it can't grow for the instance's lifetime.
57
+ const observerErrorDedup = createBoundedDedup(MAX_REPORTED_OBSERVER_ERRORS)
58
+
59
+ const reportObserverError = (err: unknown): void => {
60
+ if (!logger) return
61
+ // safeErrorMessage: a poisoned getter / throwing toString on the observer's
62
+ // own error must not escape this reporter, and the dedup key stays bounded.
63
+ const key = safeErrorMessage(err)
64
+ if (!observerErrorDedup.firstSeen(key)) return
65
+ loggingObserverError = true
66
+ try {
67
+ logger.debug('UWC observer threw', err)
68
+ } finally {
69
+ loggingObserverError = false
70
+ }
71
+ }
72
+
73
+ for (const name of UWC_EVENT_NAMES) {
74
+ if (name === 'change') continue
75
+
76
+ if (name === 'log') {
77
+ unsubscribers.push(
78
+ eventManager.on('log', data => {
79
+ if (!observer.onLog) return
80
+ // The observer-error debug log re-enters here as a `log` event; drop it
81
+ // so we never forward our own failure notice back to the bad observer.
82
+ if (loggingObserverError) return
83
+ const { level, message, args } = data as UWCEventMap['log']
84
+ try {
85
+ // Scrub the message too, not just args: the message reaches the same
86
+ // consumer sink, so the egress contract can't rely on every internal
87
+ // log string staying free of dynamic (potentially sensitive) data.
88
+ observer.onLog(level, scrubMessage(message), scrubArgs(args))
89
+ } catch (err) {
90
+ // A throwing observer must not break the dispatch chain.
91
+ reportObserverError(err)
92
+ }
93
+ })
94
+ )
95
+ continue
96
+ }
97
+
98
+ unsubscribers.push(
99
+ eventManager.on(name, data => {
100
+ if (!observer.onEvent) return
101
+ // Resolve the id in its OWN guard: a throwing getter is a config
102
+ // problem, not an observer failure — degrade to "no id" (the getter's
103
+ // documented contract) instead of dropping the record.
104
+ let correlationId: string | undefined
105
+ try {
106
+ correlationId = getCorrelationId()
107
+ } catch {
108
+ correlationId = undefined
109
+ }
110
+ try {
111
+ // Normalize OUTSIDE the EventManager's own try/catch (which guards the
112
+ // listener, not the work the listener does), then hand off.
113
+ observer.onEvent(
114
+ normalizeEvent(name, data, sdkSessionId, now(), correlationId)
115
+ )
116
+ } catch (err) {
117
+ // Same: never let a bad observer break other listeners.
118
+ reportObserverError(err)
119
+ }
120
+ })
121
+ )
122
+ }
123
+
124
+ return () => {
125
+ for (const unsubscribe of unsubscribers) unsubscribe()
126
+ unsubscribers.length = 0
127
+ }
128
+ }
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { EventManager } from '../managers/event-manager'
3
+ import type { UWCEventMap } from '../events'
4
+ import {
5
+ trackFocusDuringHandoff,
6
+ type FocusDocumentLike,
7
+ type FocusWindowLike
8
+ } from './focus-tracker'
9
+
10
+ type Hidden = UWCEventMap['pageHiddenDuringHandoff']
11
+ type Visible = UWCEventMap['pageVisibleDuringHandoff']
12
+
13
+ function makeFakeDoc() {
14
+ const listeners: Record<string, Array<() => void>> = {}
15
+ const doc: FocusDocumentLike & { visibilityState: string } = {
16
+ visibilityState: 'visible',
17
+ hidden: false,
18
+ addEventListener: (type, cb) => {
19
+ ;(listeners[type] ??= []).push(cb)
20
+ },
21
+ removeEventListener: (type, cb) => {
22
+ listeners[type] = (listeners[type] ?? []).filter(l => l !== cb)
23
+ }
24
+ }
25
+ const fire = (type: string) => {
26
+ for (const cb of [...(listeners[type] ?? [])]) cb()
27
+ }
28
+ return { doc, listeners, fire }
29
+ }
30
+
31
+ // `pageshow` is a WINDOW event (per the HTML spec / MDN it is "sent to a
32
+ // Window"), so the tracker takes a window-like target too — faked separately
33
+ // from the document so the tests exercise the real target split.
34
+ function makeFakeWin() {
35
+ const listeners: Record<string, Array<() => void>> = {}
36
+ const win: FocusWindowLike = {
37
+ addEventListener: (type, cb) => {
38
+ ;(listeners[type] ??= []).push(cb)
39
+ },
40
+ removeEventListener: (type, cb) => {
41
+ listeners[type] = (listeners[type] ?? []).filter(l => l !== cb)
42
+ }
43
+ }
44
+ const fire = (type: string) => {
45
+ for (const cb of [...(listeners[type] ?? [])]) cb()
46
+ }
47
+ return { win, listeners, fire }
48
+ }
49
+
50
+ describe('trackFocusDuringHandoff', () => {
51
+ it('dedupes consecutive same-state emits (iOS bfcache fires pageshow AND visibilitychange)', () => {
52
+ // One physical return from the wallet can produce BOTH a window `pageshow`
53
+ // and a document `visibilitychange`→visible. Without dedup the documented
54
+ // hidden/visible pairing breaks to two visibles per return.
55
+ const em = new EventManager()
56
+ const hidden: Hidden[] = []
57
+ const visible: Visible[] = []
58
+ em.on('pageHiddenDuringHandoff', e => hidden.push(e))
59
+ em.on('pageVisibleDuringHandoff', e => visible.push(e))
60
+
61
+ const { doc, fire: fireDoc } = makeFakeDoc()
62
+ const { win, fire: fireWin } = makeFakeWin()
63
+ trackFocusDuringHandoff(em, 'h-dup', 'mobile', doc, win)
64
+
65
+ // Leave for the wallet…
66
+ doc.visibilityState = 'hidden'
67
+ fireDoc('visibilitychange')
68
+ expect(hidden).toHaveLength(1)
69
+
70
+ // …and return: bfcache restore fires both signals for ONE return.
71
+ doc.visibilityState = 'visible'
72
+ fireWin('pageshow')
73
+ fireDoc('visibilitychange')
74
+ expect(visible).toHaveLength(1)
75
+
76
+ // A genuine second round-trip still emits.
77
+ doc.visibilityState = 'hidden'
78
+ fireDoc('visibilitychange')
79
+ expect(hidden).toHaveLength(2)
80
+ })
81
+
82
+ it('emits hidden then visible across a visibility round-trip', () => {
83
+ const em = new EventManager()
84
+ const hidden: Hidden[] = []
85
+ const visible: Visible[] = []
86
+ em.on('pageHiddenDuringHandoff', e => hidden.push(e))
87
+ em.on('pageVisibleDuringHandoff', e => visible.push(e))
88
+
89
+ const { doc, fire } = makeFakeDoc()
90
+ const { win } = makeFakeWin()
91
+ trackFocusDuringHandoff(em, 'h-1', 'mobile', doc, win)
92
+
93
+ doc.visibilityState = 'hidden'
94
+ doc.hidden = true
95
+ fire('visibilitychange')
96
+ doc.visibilityState = 'visible'
97
+ doc.hidden = false
98
+ fire('visibilitychange')
99
+
100
+ expect(hidden).toEqual([{ handoffId: 'h-1', platform: 'mobile' }])
101
+ expect(visible).toEqual([{ handoffId: 'h-1', platform: 'mobile' }])
102
+ })
103
+
104
+ it('treats a bfcache pageshow ON THE WINDOW as a "returned" signal', () => {
105
+ const em = new EventManager()
106
+ const visible: Visible[] = []
107
+ em.on('pageVisibleDuringHandoff', e => visible.push(e))
108
+
109
+ const { doc } = makeFakeDoc()
110
+ const { win, fire: fireWin } = makeFakeWin()
111
+ trackFocusDuringHandoff(em, 'h-2', 'webview', doc, win)
112
+ fireWin('pageshow')
113
+
114
+ expect(visible).toEqual([{ handoffId: 'h-2', platform: 'webview' }])
115
+ })
116
+
117
+ it('does NOT rely on a document-level pageshow (a Window event never reaches document)', () => {
118
+ // Regression: the listener was originally attached to `document`, where
119
+ // real browsers never deliver `pageshow` — the fake doc masked it. The
120
+ // tracker must place nothing pageshow-related on the document target.
121
+ const em = new EventManager()
122
+ const visible: Visible[] = []
123
+ em.on('pageVisibleDuringHandoff', e => visible.push(e))
124
+
125
+ const { doc, listeners: docListeners, fire: fireDoc } = makeFakeDoc()
126
+ const { win } = makeFakeWin()
127
+ trackFocusDuringHandoff(em, 'h-2b', 'webview', doc, win)
128
+
129
+ expect(docListeners['pageshow'] ?? []).toHaveLength(0)
130
+ fireDoc('pageshow')
131
+ expect(visible).toEqual([])
132
+ })
133
+
134
+ it('tracks pageshow even when there is no document (window-only runtime)', () => {
135
+ const em = new EventManager()
136
+ const visible: Visible[] = []
137
+ em.on('pageVisibleDuringHandoff', e => visible.push(e))
138
+
139
+ const { win, fire } = makeFakeWin()
140
+ const stop = trackFocusDuringHandoff(em, 'h-2c', 'mobile', undefined, win)
141
+ fire('pageshow')
142
+
143
+ expect(visible).toEqual([{ handoffId: 'h-2c', platform: 'mobile' }])
144
+ expect(() => stop()).not.toThrow()
145
+ })
146
+
147
+ it('still tracks visibility when there is no window-like target', () => {
148
+ const em = new EventManager()
149
+ const hidden: Hidden[] = []
150
+ em.on('pageHiddenDuringHandoff', e => hidden.push(e))
151
+
152
+ const { doc, fire } = makeFakeDoc()
153
+ const stop = trackFocusDuringHandoff(em, 'h-2d', 'desktop', doc, undefined)
154
+ doc.visibilityState = 'hidden'
155
+ fire('visibilitychange')
156
+
157
+ expect(hidden).toEqual([{ handoffId: 'h-2d', platform: 'desktop' }])
158
+ expect(() => stop()).not.toThrow()
159
+ })
160
+
161
+ it('teardown removes the listeners — no events after the handoff settles', () => {
162
+ const em = new EventManager()
163
+ const all: string[] = []
164
+ em.on('pageHiddenDuringHandoff', () => all.push('hidden'))
165
+ em.on('pageVisibleDuringHandoff', () => all.push('visible'))
166
+
167
+ const { doc, fire: fireDoc } = makeFakeDoc()
168
+ const { win, listeners: winListeners, fire: fireWin } = makeFakeWin()
169
+ const stop = trackFocusDuringHandoff(em, 'h-3', 'desktop', doc, win)
170
+ stop()
171
+
172
+ doc.visibilityState = 'hidden'
173
+ fireDoc('visibilitychange')
174
+ fireWin('pageshow')
175
+
176
+ expect(all).toEqual([])
177
+ expect(winListeners['pageshow'] ?? []).toHaveLength(0)
178
+ })
179
+
180
+ it('is a no-op (no throw) when there is neither document nor window (SSR)', () => {
181
+ const em = new EventManager()
182
+ const stop = trackFocusDuringHandoff(
183
+ em,
184
+ 'h-4',
185
+ 'unknown',
186
+ undefined,
187
+ undefined
188
+ )
189
+ expect(() => stop()).not.toThrow()
190
+ })
191
+ })
@@ -0,0 +1,96 @@
1
+ import type { Platform } from '@meshconnect/uwc-types'
2
+ import type { EventManager } from '../managers/event-manager'
3
+
4
+ /**
5
+ * Minimal `document` surface this tracker touches. Declared locally (rather than
6
+ * relying on the DOM lib) so the module is SSR-safe and trivially testable with a
7
+ * fake — no jsdom required.
8
+ */
9
+ export interface FocusDocumentLike {
10
+ visibilityState?: string
11
+ hidden?: boolean
12
+ addEventListener(type: string, listener: () => void): void
13
+ removeEventListener(type: string, listener: () => void): void
14
+ }
15
+
16
+ /**
17
+ * Minimal `window` surface for the `pageshow` listener. Separate from
18
+ * `FocusDocumentLike` because the two events have different targets:
19
+ * `visibilitychange` fires on `document`, while `pageshow` is a WINDOW event
20
+ * (HTML spec / MDN: "sent to a Window") — a document-level listener would never
21
+ * fire in a real browser.
22
+ */
23
+ export interface FocusWindowLike {
24
+ addEventListener(type: string, listener: () => void): void
25
+ removeEventListener(type: string, listener: () => void): void
26
+ }
27
+
28
+ function resolveDocument(): FocusDocumentLike | undefined {
29
+ const doc = (globalThis as { document?: FocusDocumentLike }).document
30
+ return doc && typeof doc.addEventListener === 'function' ? doc : undefined
31
+ }
32
+
33
+ function resolveWindow(): FocusWindowLike | undefined {
34
+ const win = (globalThis as { window?: FocusWindowLike }).window
35
+ return win && typeof win.addEventListener === 'function' ? win : undefined
36
+ }
37
+
38
+ /**
39
+ * While a wallet handoff is pending, emit a FACT on every tab-visibility
40
+ * transition — `pageHiddenDuringHandoff` when the page is backgrounded (the user
41
+ * likely left for the wallet app), `pageVisibleDuringHandoff` when it returns
42
+ * (including a bfcache `pageshow`, which iOS uses when coming back from a wallet).
43
+ *
44
+ * This is the raw signal behind "did the user actually app-switch to the wallet?"
45
+ * for deep-link flows — it does NOT interpret abandonment. The listeners are
46
+ * scoped to the single handoff: attach right after `awaitingWallet`, and the
47
+ * returned teardown (called the instant the op settles) removes them, so the
48
+ * page's visibility is only observed during the blind window. SSR-safe (no-op
49
+ * when there is neither `document` nor `window`).
50
+ *
51
+ * @returns a teardown that removes the listeners. Always call it (even when
52
+ * nothing was attached — it's a no-op then) to keep the call site symmetric.
53
+ */
54
+ export function trackFocusDuringHandoff(
55
+ eventManager: EventManager,
56
+ handoffId: string,
57
+ platform: Platform,
58
+ doc: FocusDocumentLike | undefined = resolveDocument(),
59
+ win: FocusWindowLike | undefined = resolveWindow()
60
+ ): () => void {
61
+ if (!doc && !win) return () => {}
62
+
63
+ // One physical return can fire BOTH `pageshow` (window) and a
64
+ // `visibilitychange`→visible (document) — an iOS bfcache restore does. Dedupe
65
+ // consecutive same-state emits so the hidden/visible events actually pair.
66
+ let lastState: 'hidden' | 'visible' | undefined
67
+ const emitState = (state: 'hidden' | 'visible') => {
68
+ if (state === lastState) return
69
+ lastState = state
70
+ eventManager.emit(
71
+ state === 'hidden'
72
+ ? 'pageHiddenDuringHandoff'
73
+ : 'pageVisibleDuringHandoff',
74
+ { handoffId, platform }
75
+ )
76
+ }
77
+
78
+ const onVisibility = () => {
79
+ const hidden = doc?.visibilityState === 'hidden' || doc?.hidden === true
80
+ emitState(hidden ? 'hidden' : 'visible')
81
+ }
82
+ // `pageshow` fires on a bfcache restore (returning from the wallet app on iOS),
83
+ // a "came back" signal `visibilitychange` doesn't always deliver. It targets
84
+ // the WINDOW — attaching it to `document` would silently never fire.
85
+ const onPageShow = () => {
86
+ emitState('visible')
87
+ }
88
+
89
+ doc?.addEventListener('visibilitychange', onVisibility)
90
+ win?.addEventListener('pageshow', onPageShow)
91
+
92
+ return () => {
93
+ doc?.removeEventListener('visibilitychange', onVisibility)
94
+ win?.removeEventListener('pageshow', onPageShow)
95
+ }
96
+ }