@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.
- package/README.md +247 -76
- package/dist/events.d.ts +241 -5
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +77 -1
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/logger/create-logger.d.ts +32 -0
- package/dist/logger/create-logger.d.ts.map +1 -0
- package/dist/logger/create-logger.js +79 -0
- package/dist/logger/create-logger.js.map +1 -0
- package/dist/managers/event-manager.d.ts.map +1 -1
- package/dist/managers/event-manager.js +5 -1
- package/dist/managers/event-manager.js.map +1 -1
- package/dist/observability/connect-observer.d.ts +33 -0
- package/dist/observability/connect-observer.d.ts.map +1 -0
- package/dist/observability/connect-observer.js +115 -0
- package/dist/observability/connect-observer.js.map +1 -0
- package/dist/observability/focus-tracker.d.ts +42 -0
- package/dist/observability/focus-tracker.d.ts.map +1 -0
- package/dist/observability/focus-tracker.js +57 -0
- package/dist/observability/focus-tracker.js.map +1 -0
- package/dist/observability/instrument-handoff.d.ts +54 -0
- package/dist/observability/instrument-handoff.d.ts.map +1 -0
- package/dist/observability/instrument-handoff.js +173 -0
- package/dist/observability/instrument-handoff.js.map +1 -0
- package/dist/observability/lifecycle-bridge.d.ts +69 -0
- package/dist/observability/lifecycle-bridge.d.ts.map +1 -0
- package/dist/observability/lifecycle-bridge.js +155 -0
- package/dist/observability/lifecycle-bridge.js.map +1 -0
- package/dist/observability/scrub.d.ts +31 -0
- package/dist/observability/scrub.d.ts.map +1 -0
- package/dist/observability/scrub.js +167 -0
- package/dist/observability/scrub.js.map +1 -0
- package/dist/observability/telemetry.d.ts +157 -0
- package/dist/observability/telemetry.d.ts.map +1 -0
- package/dist/observability/telemetry.js +373 -0
- package/dist/observability/telemetry.js.map +1 -0
- package/dist/services/connection-service.d.ts +23 -1
- package/dist/services/connection-service.d.ts.map +1 -1
- package/dist/services/connection-service.js +128 -19
- package/dist/services/connection-service.js.map +1 -1
- package/dist/services/network-switch-service.d.ts +2 -1
- package/dist/services/network-switch-service.d.ts.map +1 -1
- package/dist/services/network-switch-service.js +45 -16
- package/dist/services/network-switch-service.js.map +1 -1
- package/dist/services/signature-service.d.ts +14 -7
- package/dist/services/signature-service.d.ts.map +1 -1
- package/dist/services/signature-service.js +56 -58
- package/dist/services/signature-service.js.map +1 -1
- package/dist/services/transaction-service.d.ts +19 -5
- package/dist/services/transaction-service.d.ts.map +1 -1
- package/dist/services/transaction-service.js +55 -38
- package/dist/services/transaction-service.js.map +1 -1
- package/dist/universal-wallet-connector.d.ts +56 -1
- package/dist/universal-wallet-connector.d.ts.map +1 -1
- package/dist/universal-wallet-connector.js +207 -110
- package/dist/universal-wallet-connector.js.map +1 -1
- package/dist/utils/id.d.ts +2 -0
- package/dist/utils/id.d.ts.map +1 -0
- package/dist/utils/id.js +41 -0
- package/dist/utils/id.js.map +1 -0
- package/dist/utils/resolve-session-provider.d.ts +10 -0
- package/dist/utils/resolve-session-provider.d.ts.map +1 -0
- package/dist/utils/resolve-session-provider.js +21 -0
- package/dist/utils/resolve-session-provider.js.map +1 -0
- package/dist/utils/to-wallet-error.d.ts +35 -0
- package/dist/utils/to-wallet-error.d.ts.map +1 -0
- package/dist/utils/to-wallet-error.js +130 -0
- package/dist/utils/to-wallet-error.js.map +1 -0
- package/dist/utils/validate-wallet-session.d.ts +15 -0
- package/dist/utils/validate-wallet-session.d.ts.map +1 -0
- package/dist/utils/validate-wallet-session.js +29 -0
- package/dist/utils/validate-wallet-session.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/package.json +6 -6
- package/src/events.ts +326 -14
- package/src/index.ts +10 -0
- package/src/logger/create-logger.test.ts +126 -0
- package/src/logger/create-logger.ts +112 -0
- package/src/managers/event-manager.test.ts +55 -0
- package/src/managers/event-manager.ts +5 -1
- package/src/observability/connect-observer.test.ts +214 -0
- package/src/observability/connect-observer.ts +128 -0
- package/src/observability/focus-tracker.test.ts +191 -0
- package/src/observability/focus-tracker.ts +96 -0
- package/src/observability/instrument-handoff.test.ts +397 -0
- package/src/observability/instrument-handoff.ts +231 -0
- package/src/observability/lifecycle-bridge.test.ts +398 -0
- package/src/observability/lifecycle-bridge.ts +216 -0
- package/src/observability/scrub.test.ts +183 -0
- package/src/observability/scrub.ts +174 -0
- package/src/observability/telemetry.test.ts +549 -0
- package/src/observability/telemetry.ts +546 -0
- package/src/services/connection-service.test.ts +261 -1
- package/src/services/connection-service.ts +167 -38
- package/src/services/network-switch-service.test.ts +51 -1
- package/src/services/network-switch-service.ts +56 -24
- package/src/services/signature-service.test.ts +181 -76
- package/src/services/signature-service.ts +75 -85
- package/src/services/transaction-service.test.ts +270 -69
- package/src/services/transaction-service.ts +74 -51
- package/src/universal-wallet-connector.instrumentation-guard.test.ts +199 -0
- package/src/universal-wallet-connector.observability.test.ts +610 -0
- package/src/universal-wallet-connector.test.ts +27 -146
- package/src/universal-wallet-connector.ts +279 -142
- package/src/utils/id.test.ts +15 -0
- package/src/utils/id.ts +48 -0
- package/src/utils/resolve-session-provider.test.ts +54 -0
- package/src/utils/resolve-session-provider.ts +25 -0
- package/src/utils/to-wallet-error.test.ts +181 -0
- package/src/utils/to-wallet-error.ts +152 -0
- package/src/utils/validate-wallet-session.test.ts +77 -0
- package/src/utils/validate-wallet-session.ts +40 -0
- package/src/version.test.ts +11 -0
- 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
|
-
|
|
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
|
+
}
|