@meshconnect/uwc-core 1.0.0 → 1.0.1-snapshot.c7e65ce
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 +211 -66
- package/dist/events.d.ts +69 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +40 -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 +1 -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 +69 -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 +22 -0
- package/dist/observability/connect-observer.d.ts.map +1 -0
- package/dist/observability/connect-observer.js +60 -0
- package/dist/observability/connect-observer.js.map +1 -0
- package/dist/observability/instrument-handoff.d.ts +39 -0
- package/dist/observability/instrument-handoff.d.ts.map +1 -0
- package/dist/observability/instrument-handoff.js +86 -0
- package/dist/observability/instrument-handoff.js.map +1 -0
- package/dist/observability/scrub.d.ts +30 -0
- package/dist/observability/scrub.d.ts.map +1 -0
- package/dist/observability/scrub.js +135 -0
- package/dist/observability/scrub.js.map +1 -0
- package/dist/observability/telemetry.d.ts +77 -0
- package/dist/observability/telemetry.d.ts.map +1 -0
- package/dist/observability/telemetry.js +138 -0
- package/dist/observability/telemetry.js.map +1 -0
- package/dist/services/connection-service.d.ts +2 -1
- package/dist/services/connection-service.d.ts.map +1 -1
- package/dist/services/connection-service.js +35 -15
- 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 +33 -15
- package/dist/services/network-switch-service.js.map +1 -1
- package/dist/universal-wallet-connector.d.ts +51 -2
- package/dist/universal-wallet-connector.d.ts.map +1 -1
- package/dist/universal-wallet-connector.js +163 -61
- 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/to-wallet-error.d.ts +16 -0
- package/dist/utils/to-wallet-error.d.ts.map +1 -0
- package/dist/utils/to-wallet-error.js +33 -0
- package/dist/utils/to-wallet-error.js.map +1 -0
- package/package.json +5 -5
- package/src/events.ts +114 -1
- package/src/index.ts +6 -0
- package/src/logger/create-logger.test.ts +111 -0
- package/src/logger/create-logger.ts +101 -0
- package/src/managers/event-manager.test.ts +25 -0
- package/src/managers/event-manager.ts +5 -1
- package/src/observability/connect-observer.test.ts +100 -0
- package/src/observability/connect-observer.ts +71 -0
- package/src/observability/instrument-handoff.test.ts +150 -0
- package/src/observability/instrument-handoff.ts +122 -0
- package/src/observability/scrub.test.ts +109 -0
- package/src/observability/scrub.ts +142 -0
- package/src/observability/telemetry.test.ts +134 -0
- package/src/observability/telemetry.ts +211 -0
- package/src/services/connection-service.test.ts +66 -1
- package/src/services/connection-service.ts +54 -32
- package/src/services/network-switch-service.test.ts +51 -1
- package/src/services/network-switch-service.ts +43 -23
- package/src/universal-wallet-connector.observability.test.ts +265 -0
- package/src/universal-wallet-connector.ts +244 -71
- package/src/utils/id.test.ts +15 -0
- package/src/utils/id.ts +48 -0
- package/src/utils/to-wallet-error.ts +36 -0
package/src/events.ts
CHANGED
|
@@ -4,7 +4,12 @@ import type {
|
|
|
4
4
|
WalletMetadata,
|
|
5
5
|
ConnectionMode,
|
|
6
6
|
WalletError,
|
|
7
|
-
EVMCapabilities
|
|
7
|
+
EVMCapabilities,
|
|
8
|
+
LogLevel,
|
|
9
|
+
WalletFlowType,
|
|
10
|
+
Platform,
|
|
11
|
+
Namespace,
|
|
12
|
+
NetworkId
|
|
8
13
|
} from '@meshconnect/uwc-types'
|
|
9
14
|
|
|
10
15
|
/**
|
|
@@ -15,7 +20,9 @@ export type UWCOperation =
|
|
|
15
20
|
| 'disconnect'
|
|
16
21
|
| 'switchNetwork'
|
|
17
22
|
| 'signMessage'
|
|
23
|
+
| 'signTypedData'
|
|
18
24
|
| 'sendTransaction'
|
|
25
|
+
| 'signSolanaTransaction'
|
|
19
26
|
| 'getWalletCapabilities'
|
|
20
27
|
| 'initialize'
|
|
21
28
|
|
|
@@ -60,6 +67,69 @@ export interface UWCEventMap {
|
|
|
60
67
|
/** A user-initiated operation failed. */
|
|
61
68
|
error: { error: WalletError; operation: UWCOperation }
|
|
62
69
|
|
|
70
|
+
// ---- Observability (telemetry-only; see TELEMETRY_ONLY_EVENTS) -------------
|
|
71
|
+
// These describe what happened, not a state change, so they intentionally do
|
|
72
|
+
// NOT cascade to the legacy `change` channel (which drives React re-renders).
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A line emitted through the internal `Logger`. Fires for ALL levels regardless
|
|
76
|
+
* of the console threshold so an observer can capture `debug`/`info` even when
|
|
77
|
+
* the console is quiet. `args` is scrubbed before reaching `observer.onLog`.
|
|
78
|
+
*/
|
|
79
|
+
log: { level: LogLevel; message: string; args: unknown[] }
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Emitted immediately BEFORE control is handed to the wallet for a boundary op
|
|
83
|
+
* (the moment the blind window opens). Pair with exactly one terminal event via
|
|
84
|
+
* `handoffId`. Carries no signing payload and no address — only routing facets.
|
|
85
|
+
*/
|
|
86
|
+
awaitingWallet: {
|
|
87
|
+
operation: UWCOperation
|
|
88
|
+
connectionMode: ConnectionMode
|
|
89
|
+
walletId?: string | undefined
|
|
90
|
+
/** CAIP namespace (eip155 | solana | tron | tvm …) — not an address. */
|
|
91
|
+
namespace?: Namespace | undefined
|
|
92
|
+
/** CAIP chain id (e.g. `eip155:1`) — not an address. */
|
|
93
|
+
chainId?: NetworkId | undefined
|
|
94
|
+
handoffId: string
|
|
95
|
+
/** Derived, best-effort flow taxonomy (heuristic — see deriveWalletFlowType). */
|
|
96
|
+
walletFlowType: WalletFlowType
|
|
97
|
+
/** Coarse runtime surface (heuristic — see derivePlatform). */
|
|
98
|
+
platform: Platform
|
|
99
|
+
timestamp: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The wallet resolved the op successfully. Closes a handoff. */
|
|
103
|
+
walletSucceeded: {
|
|
104
|
+
handoffId: string
|
|
105
|
+
operation: UWCOperation
|
|
106
|
+
durationMs: number
|
|
107
|
+
}
|
|
108
|
+
/** The user explicitly rejected (4001 / WalletError.type === 'rejected'). Closes a handoff. */
|
|
109
|
+
walletRejected: {
|
|
110
|
+
handoffId: string
|
|
111
|
+
operation: UWCOperation
|
|
112
|
+
durationMs: number
|
|
113
|
+
}
|
|
114
|
+
/** The op failed for any non-rejection reason. Closes a handoff. */
|
|
115
|
+
walletFailed: {
|
|
116
|
+
handoffId: string
|
|
117
|
+
operation: UWCOperation
|
|
118
|
+
durationMs: number
|
|
119
|
+
error: WalletError
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Side marker: the deadline passed with the op still pending. OBSERVATIONAL —
|
|
123
|
+
* it does NOT reject, abort, or mutate state; the wallet may still complete and
|
|
124
|
+
* produce the real terminal. Never fires for an op that settled before the
|
|
125
|
+
* deadline. Not itself a terminal.
|
|
126
|
+
*/
|
|
127
|
+
walletTimedOut: {
|
|
128
|
+
handoffId: string
|
|
129
|
+
operation: UWCOperation
|
|
130
|
+
durationMs: number
|
|
131
|
+
}
|
|
132
|
+
|
|
63
133
|
/**
|
|
64
134
|
* Back-compat catch-all. Fires after every other event above.
|
|
65
135
|
* New code should prefer the specific events.
|
|
@@ -67,7 +137,50 @@ export interface UWCEventMap {
|
|
|
67
137
|
change: void
|
|
68
138
|
}
|
|
69
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Events that describe telemetry rather than a state change. `EventManager.emit`
|
|
142
|
+
* skips the legacy `change` cascade for these so high-frequency logging / handoff
|
|
143
|
+
* instrumentation can't trigger React re-renders through `subscribe()`.
|
|
144
|
+
*/
|
|
145
|
+
export const TELEMETRY_ONLY_EVENTS: ReadonlySet<UWCEventName> = new Set([
|
|
146
|
+
'log',
|
|
147
|
+
'awaitingWallet',
|
|
148
|
+
'walletSucceeded',
|
|
149
|
+
'walletRejected',
|
|
150
|
+
'walletFailed',
|
|
151
|
+
'walletTimedOut'
|
|
152
|
+
])
|
|
153
|
+
|
|
70
154
|
export type UWCEventName = keyof UWCEventMap
|
|
71
155
|
export type UWCEventListener<K extends UWCEventName> = (
|
|
72
156
|
data: UWCEventMap[K]
|
|
73
157
|
) => void
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Runtime list of every event name. The `Record<UWCEventName, true>` literal
|
|
161
|
+
* forces this to stay exhaustive — adding an event to `UWCEventMap` without
|
|
162
|
+
* listing it here is a compile error — so the observer bridge can never silently
|
|
163
|
+
* drop a new event.
|
|
164
|
+
*/
|
|
165
|
+
const EVENT_NAME_FLAGS: Record<UWCEventName, true> = {
|
|
166
|
+
ready: true,
|
|
167
|
+
walletsDetected: true,
|
|
168
|
+
connecting: true,
|
|
169
|
+
connected: true,
|
|
170
|
+
disconnected: true,
|
|
171
|
+
connectionUri: true,
|
|
172
|
+
sessionChanged: true,
|
|
173
|
+
networkSwitching: true,
|
|
174
|
+
networkSwitched: true,
|
|
175
|
+
capabilitiesUpdated: true,
|
|
176
|
+
error: true,
|
|
177
|
+
log: true,
|
|
178
|
+
awaitingWallet: true,
|
|
179
|
+
walletSucceeded: true,
|
|
180
|
+
walletRejected: true,
|
|
181
|
+
walletFailed: true,
|
|
182
|
+
walletTimedOut: true,
|
|
183
|
+
change: true
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const UWC_EVENT_NAMES = Object.keys(EVENT_NAME_FLAGS) as UWCEventName[]
|
package/src/index.ts
CHANGED
|
@@ -9,4 +9,10 @@ export type {
|
|
|
9
9
|
UWCEventListener,
|
|
10
10
|
UWCOperation
|
|
11
11
|
} from './events'
|
|
12
|
+
export {
|
|
13
|
+
createLogger,
|
|
14
|
+
type ManagedLogger,
|
|
15
|
+
type CreateLoggerOptions
|
|
16
|
+
} from './logger/create-logger'
|
|
17
|
+
export type { UWCObserver, UWCTelemetryEvent } from './observability/telemetry'
|
|
12
18
|
export { toBaseUnits } from './utils/to-base-units'
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import type { Logger } from '@meshconnect/uwc-types'
|
|
3
|
+
import { createLogger } from './create-logger'
|
|
4
|
+
|
|
5
|
+
function makeSink(): Logger & { calls: Record<string, unknown[][]> } {
|
|
6
|
+
const calls: Record<string, unknown[][]> = {
|
|
7
|
+
debug: [],
|
|
8
|
+
info: [],
|
|
9
|
+
warn: [],
|
|
10
|
+
error: []
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
calls,
|
|
14
|
+
debug: (...a) => calls.debug.push(a),
|
|
15
|
+
info: (...a) => calls.info.push(a),
|
|
16
|
+
warn: (...a) => calls.warn.push(a),
|
|
17
|
+
error: (...a) => calls.error.push(a)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('createLogger', () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
delete (globalThis as { UWC_DEBUG?: unknown }).UWC_DEBUG
|
|
24
|
+
vi.restoreAllMocks()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('gates the console sink by level (default warn)', () => {
|
|
28
|
+
const sink = makeSink()
|
|
29
|
+
const log = createLogger({ sink })
|
|
30
|
+
log.debug('d')
|
|
31
|
+
log.info('i')
|
|
32
|
+
log.warn('w')
|
|
33
|
+
log.error('e')
|
|
34
|
+
expect(sink.calls.debug).toHaveLength(0)
|
|
35
|
+
expect(sink.calls.info).toHaveLength(0)
|
|
36
|
+
expect(sink.calls.warn).toHaveLength(1)
|
|
37
|
+
expect(sink.calls.error).toHaveLength(1)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('fires onLog for EVERY level, pre-gate (even below the console threshold)', () => {
|
|
41
|
+
const onLog = vi.fn()
|
|
42
|
+
const sink = makeSink()
|
|
43
|
+
const log = createLogger({ level: 'error', sink, onLog })
|
|
44
|
+
log.debug('d', 1)
|
|
45
|
+
log.error('e')
|
|
46
|
+
// onLog sees both; the sink only sees error.
|
|
47
|
+
expect(onLog).toHaveBeenCalledTimes(2)
|
|
48
|
+
expect(onLog).toHaveBeenNthCalledWith(1, 'debug', 'd', [1])
|
|
49
|
+
expect(sink.calls.debug).toHaveLength(0)
|
|
50
|
+
expect(sink.calls.error).toHaveLength(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('passes raw (message, ...args) to a custom sink — no ISO prefix', () => {
|
|
54
|
+
const sink = makeSink()
|
|
55
|
+
const log = createLogger({ level: 'debug', sink })
|
|
56
|
+
log.warn('hello', { a: 1 })
|
|
57
|
+
expect(sink.calls.warn[0]).toEqual(['hello', { a: 1 }])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('formats with an ISO/level prefix for the default console sink', () => {
|
|
61
|
+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
62
|
+
const log = createLogger({ level: 'warn' })
|
|
63
|
+
log.warn('boom', 42)
|
|
64
|
+
const firstArg = spy.mock.calls[0]?.[0] as string
|
|
65
|
+
expect(firstArg).toMatch(/WARN: boom$/)
|
|
66
|
+
expect(spy.mock.calls[0]?.[1]).toBe(42)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('setLevel changes the threshold at runtime', () => {
|
|
70
|
+
const sink = makeSink()
|
|
71
|
+
const log = createLogger({ level: 'error', sink })
|
|
72
|
+
log.info('before')
|
|
73
|
+
expect(sink.calls.info).toHaveLength(0)
|
|
74
|
+
log.setLevel('debug')
|
|
75
|
+
log.info('after')
|
|
76
|
+
expect(sink.calls.info).toHaveLength(1)
|
|
77
|
+
expect(log.getLevel()).toBe('debug')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('honours the global UWC_DEBUG override (true ⇒ debug) without recreation', () => {
|
|
81
|
+
const sink = makeSink()
|
|
82
|
+
const log = createLogger({ level: 'error', sink })
|
|
83
|
+
log.debug('hidden')
|
|
84
|
+
expect(sink.calls.debug).toHaveLength(0)
|
|
85
|
+
;(globalThis as { UWC_DEBUG?: unknown }).UWC_DEBUG = true
|
|
86
|
+
log.debug('shown')
|
|
87
|
+
expect(sink.calls.debug).toHaveLength(1)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('a string UWC_DEBUG override only lowers the threshold, never raises it', () => {
|
|
91
|
+
const sink = makeSink()
|
|
92
|
+
const log = createLogger({ level: 'debug', sink })
|
|
93
|
+
;(globalThis as { UWC_DEBUG?: unknown }).UWC_DEBUG = 'error'
|
|
94
|
+
// configured=debug, override=error → effective stays debug (min of the two).
|
|
95
|
+
log.debug('still shown')
|
|
96
|
+
expect(sink.calls.debug).toHaveLength(1)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('swallows a throwing onLog so the log call never breaks', () => {
|
|
100
|
+
const sink = makeSink()
|
|
101
|
+
const log = createLogger({
|
|
102
|
+
level: 'warn',
|
|
103
|
+
sink,
|
|
104
|
+
onLog: () => {
|
|
105
|
+
throw new Error('sink boom')
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
expect(() => log.warn('ok')).not.toThrow()
|
|
109
|
+
expect(sink.calls.warn).toHaveLength(1)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { LogLevel, Logger } from '@meshconnect/uwc-types'
|
|
2
|
+
|
|
3
|
+
const LEVEL_WEIGHT: Record<LogLevel, number> = {
|
|
4
|
+
debug: 0,
|
|
5
|
+
info: 1,
|
|
6
|
+
warn: 2,
|
|
7
|
+
error: 3
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The logger returned by `createLogger`, with runtime-tunable level controls. */
|
|
11
|
+
export interface ManagedLogger extends Logger {
|
|
12
|
+
/** Lower/raise the console threshold at runtime (e.g. from a support toggle). */
|
|
13
|
+
setLevel(level: LogLevel): void
|
|
14
|
+
/** The currently configured threshold (ignores the global override). */
|
|
15
|
+
getLevel(): LogLevel
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CreateLoggerOptions {
|
|
19
|
+
/** Console threshold. Default `'warn'` — UWC stays quiet unless asked. */
|
|
20
|
+
level?: LogLevel | undefined
|
|
21
|
+
/**
|
|
22
|
+
* Pre-gate tap: invoked for EVERY log line regardless of the console threshold.
|
|
23
|
+
* The observer bridge subscribes here so `debug`/`info` reach remote telemetry
|
|
24
|
+
* even when the console is silent. Throws are swallowed — a bad sink must not
|
|
25
|
+
* break a log call.
|
|
26
|
+
*/
|
|
27
|
+
onLog?:
|
|
28
|
+
| ((level: LogLevel, message: string, args: unknown[]) => void)
|
|
29
|
+
| undefined
|
|
30
|
+
/**
|
|
31
|
+
* Output sink for gated lines. When provided (e.g. a consumer's own logger), it
|
|
32
|
+
* receives the RAW `(message, ...args)` so it can apply its own formatting. When
|
|
33
|
+
* omitted, the default `console` sink is used with an ISO/level prefix.
|
|
34
|
+
*/
|
|
35
|
+
sink?: Logger | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the effective console threshold, honouring a runtime global override so
|
|
40
|
+
* an issue can be triaged in a wallet's in-app browser WITHOUT a redeploy:
|
|
41
|
+
*
|
|
42
|
+
* window.UWC_DEBUG = true // surface everything (debug)
|
|
43
|
+
* window.UWC_DEBUG = 'info' // surface info and above
|
|
44
|
+
*
|
|
45
|
+
* The override only ever LOWERS the threshold (shows more) — it can't hide logs a
|
|
46
|
+
* consumer explicitly opted into. Re-read on every call so toggling it mid-session
|
|
47
|
+
* takes effect immediately. SSR/Node-safe via the `globalThis` guard.
|
|
48
|
+
*/
|
|
49
|
+
function resolveWeight(configured: LogLevel): number {
|
|
50
|
+
if (typeof globalThis !== 'undefined') {
|
|
51
|
+
const override = (globalThis as { UWC_DEBUG?: unknown }).UWC_DEBUG
|
|
52
|
+
if (override === true) return LEVEL_WEIGHT.debug
|
|
53
|
+
if (typeof override === 'string' && override in LEVEL_WEIGHT) {
|
|
54
|
+
return Math.min(
|
|
55
|
+
LEVEL_WEIGHT[override as LogLevel],
|
|
56
|
+
LEVEL_WEIGHT[configured]
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return LEVEL_WEIGHT[configured]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Console-backed, level-gated logger. ~zero-dep. Two seams:
|
|
65
|
+
* - `onLog` fires pre-gate (all levels) → drives the observer.
|
|
66
|
+
* - the console sink fires only at/above the threshold.
|
|
67
|
+
*/
|
|
68
|
+
export function createLogger(options: CreateLoggerOptions = {}): ManagedLogger {
|
|
69
|
+
let configured: LogLevel = options.level ?? 'warn'
|
|
70
|
+
const customSink = options.sink
|
|
71
|
+
|
|
72
|
+
const emit = (level: LogLevel, message: string, args: unknown[]): void => {
|
|
73
|
+
// Pre-gate tap first — observability must not depend on the console threshold.
|
|
74
|
+
if (options.onLog) {
|
|
75
|
+
try {
|
|
76
|
+
options.onLog(level, message, args)
|
|
77
|
+
} catch {
|
|
78
|
+
// A failing sink must never break the caller's log statement.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (LEVEL_WEIGHT[level] < resolveWeight(configured)) return
|
|
82
|
+
if (customSink) {
|
|
83
|
+
customSink[level](message, ...args)
|
|
84
|
+
} else {
|
|
85
|
+
const line = `[${new Date().toISOString()}] ${level.toUpperCase()}: ${message}`
|
|
86
|
+
// eslint-disable-next-line no-console -- this IS the default console sink
|
|
87
|
+
console[level](line, ...args)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
debug: (message, ...args) => emit('debug', message, args),
|
|
93
|
+
info: (message, ...args) => emit('info', message, args),
|
|
94
|
+
warn: (message, ...args) => emit('warn', message, args),
|
|
95
|
+
error: (message, ...args) => emit('error', message, args),
|
|
96
|
+
setLevel: level => {
|
|
97
|
+
configured = level
|
|
98
|
+
},
|
|
99
|
+
getLevel: () => configured
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -140,5 +140,30 @@ describe('EventManager', () => {
|
|
|
140
140
|
eventManager.emit('change', undefined)
|
|
141
141
|
expect(count).toBe(1)
|
|
142
142
|
})
|
|
143
|
+
|
|
144
|
+
it('telemetry-only events do NOT cascade to the legacy change channel', () => {
|
|
145
|
+
let changeCount = 0
|
|
146
|
+
eventManager.subscribe(() => {
|
|
147
|
+
changeCount++
|
|
148
|
+
})
|
|
149
|
+
eventManager.emit('log', { level: 'debug', message: 'x', args: [] })
|
|
150
|
+
eventManager.emit('walletTimedOut', {
|
|
151
|
+
handoffId: 'h',
|
|
152
|
+
operation: 'signMessage',
|
|
153
|
+
durationMs: 1
|
|
154
|
+
})
|
|
155
|
+
// Neither should have triggered a re-render via `change`.
|
|
156
|
+
expect(changeCount).toBe(0)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('state-change events still cascade to change alongside telemetry events', () => {
|
|
160
|
+
let changeCount = 0
|
|
161
|
+
eventManager.subscribe(() => {
|
|
162
|
+
changeCount++
|
|
163
|
+
})
|
|
164
|
+
eventManager.emit('log', { level: 'debug', message: 'x', args: [] })
|
|
165
|
+
eventManager.emit('disconnected', undefined)
|
|
166
|
+
expect(changeCount).toBe(1)
|
|
167
|
+
})
|
|
143
168
|
})
|
|
144
169
|
})
|
|
@@ -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,100 @@
|
|
|
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 { connectObserver } from './connect-observer'
|
|
5
|
+
import type { UWCTelemetryEvent } from './telemetry'
|
|
6
|
+
|
|
7
|
+
const FIXED_NOW = 1_700_000_000_000
|
|
8
|
+
|
|
9
|
+
describe('connectObserver', () => {
|
|
10
|
+
it('forwards typed events to onEvent as normalized records', () => {
|
|
11
|
+
const em = new EventManager()
|
|
12
|
+
const events: UWCTelemetryEvent[] = []
|
|
13
|
+
connectObserver(
|
|
14
|
+
em,
|
|
15
|
+
{ onEvent: e => events.push(e) },
|
|
16
|
+
() => 'sess',
|
|
17
|
+
() => FIXED_NOW
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
em.emit('connecting', { connectionMode: 'injected', walletId: 'metamask' })
|
|
21
|
+
|
|
22
|
+
expect(events).toHaveLength(1)
|
|
23
|
+
expect(events[0]).toMatchObject({
|
|
24
|
+
name: 'connecting',
|
|
25
|
+
operation: 'connect',
|
|
26
|
+
walletId: 'metamask',
|
|
27
|
+
sdkSessionId: 'sess',
|
|
28
|
+
timestamp: FIXED_NOW
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('routes log to onLog (scrubbed args), not onEvent', () => {
|
|
33
|
+
const em = new EventManager()
|
|
34
|
+
const onEvent = vi.fn()
|
|
35
|
+
const onLog = vi.fn()
|
|
36
|
+
connectObserver(em, { onEvent, onLog }, () => 'sess')
|
|
37
|
+
|
|
38
|
+
em.emit('log', {
|
|
39
|
+
level: 'info',
|
|
40
|
+
message: 'hi',
|
|
41
|
+
args: [{ secret: 's', id: 1 }]
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(onEvent).not.toHaveBeenCalled()
|
|
45
|
+
expect(onLog).toHaveBeenCalledTimes(1)
|
|
46
|
+
const [level, message, args] = onLog.mock.calls[0]
|
|
47
|
+
expect(level).toBe('info')
|
|
48
|
+
expect(message).toBe('hi')
|
|
49
|
+
expect((args[0] as Record<string, unknown>).secret).toBe('[Redacted]')
|
|
50
|
+
expect((args[0] as Record<string, unknown>).id).toBe(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('does not forward the legacy change event', () => {
|
|
54
|
+
const em = new EventManager()
|
|
55
|
+
const onEvent = vi.fn()
|
|
56
|
+
connectObserver(em, { onEvent }, () => 'sess')
|
|
57
|
+
em.emit('change', undefined)
|
|
58
|
+
expect(onEvent).not.toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('teardown removes every subscription', () => {
|
|
62
|
+
const em = new EventManager()
|
|
63
|
+
const onEvent = vi.fn()
|
|
64
|
+
const teardown = connectObserver(em, { onEvent }, () => 'sess')
|
|
65
|
+
teardown()
|
|
66
|
+
em.emit('connecting', { connectionMode: 'injected', walletId: 'x' })
|
|
67
|
+
expect(onEvent).not.toHaveBeenCalled()
|
|
68
|
+
expect(em.getListenerCount()).toBe(0)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('does not leak the wallet address through a connected event', () => {
|
|
72
|
+
const em = new EventManager()
|
|
73
|
+
const events: UWCTelemetryEvent[] = []
|
|
74
|
+
connectObserver(em, { onEvent: e => events.push(e) }, () => 'sess')
|
|
75
|
+
const session = {
|
|
76
|
+
connectionMode: 'injected',
|
|
77
|
+
activeNetwork: { namespace: 'eip155', id: 'eip155:1' },
|
|
78
|
+
activeWallet: { id: 'metamask' },
|
|
79
|
+
activeAddress: '0xSECRETADDR'
|
|
80
|
+
} as unknown as Session
|
|
81
|
+
em.emit('connected', { session })
|
|
82
|
+
expect(JSON.stringify(events)).not.toContain('0xSECRETADDR')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('a throwing observer never breaks the dispatch chain', () => {
|
|
86
|
+
const em = new EventManager()
|
|
87
|
+
connectObserver(
|
|
88
|
+
em,
|
|
89
|
+
{
|
|
90
|
+
onEvent: () => {
|
|
91
|
+
throw new Error('observer boom')
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
() => 'sess'
|
|
95
|
+
)
|
|
96
|
+
expect(() =>
|
|
97
|
+
em.emit('connecting', { connectionMode: 'injected', walletId: 'x' })
|
|
98
|
+
).not.toThrow()
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { EventManager } from '../managers/event-manager'
|
|
2
|
+
import { UWC_EVENT_NAMES } from '../events'
|
|
3
|
+
import type { UWCEventMap } from '../events'
|
|
4
|
+
import { normalizeEvent, type UWCObserver } from './telemetry'
|
|
5
|
+
import { scrubArgs } from './scrub'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bridge the internal event bus to a consumer-supplied `UWCObserver`.
|
|
9
|
+
*
|
|
10
|
+
* Subscribes to every `UWCEventMap` event, normalizes each to a PII-safe
|
|
11
|
+
* `UWCTelemetryEvent`, and forwards it to `observer.onEvent`. The `log` event is
|
|
12
|
+
* routed to `observer.onLog` instead (with scrubbed args) — it's the logging
|
|
13
|
+
* channel, not a telemetry record. `change` is skipped: it's the legacy
|
|
14
|
+
* re-render cascade and carries no payload.
|
|
15
|
+
*
|
|
16
|
+
* Returns a teardown function that removes every subscription. The caller MUST
|
|
17
|
+
* invoke it on connector teardown — leaving these attached is the same listener-
|
|
18
|
+
* leak class as the historical `BridgeParent.destroy()` zombie bug.
|
|
19
|
+
*
|
|
20
|
+
* @param getCorrelationId - resolves the correlation id stamped on every event,
|
|
21
|
+
* called per-event so a consumer-supplied id that arrives/changes after
|
|
22
|
+
* construction is honoured. Must not throw (the caller pre-wraps it).
|
|
23
|
+
* @param now - clock injectable for deterministic tests; defaults to `Date.now`.
|
|
24
|
+
*/
|
|
25
|
+
export function connectObserver(
|
|
26
|
+
eventManager: EventManager,
|
|
27
|
+
observer: UWCObserver,
|
|
28
|
+
getCorrelationId: () => string,
|
|
29
|
+
now: () => number = Date.now
|
|
30
|
+
): () => void {
|
|
31
|
+
const unsubscribers: Array<() => void> = []
|
|
32
|
+
|
|
33
|
+
for (const name of UWC_EVENT_NAMES) {
|
|
34
|
+
if (name === 'change') continue
|
|
35
|
+
|
|
36
|
+
if (name === 'log') {
|
|
37
|
+
unsubscribers.push(
|
|
38
|
+
eventManager.on('log', data => {
|
|
39
|
+
if (!observer.onLog) return
|
|
40
|
+
const { level, message, args } = data as UWCEventMap['log']
|
|
41
|
+
try {
|
|
42
|
+
observer.onLog(level, message, scrubArgs(args))
|
|
43
|
+
} catch {
|
|
44
|
+
// A throwing observer must not break the dispatch chain.
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
unsubscribers.push(
|
|
52
|
+
eventManager.on(name, data => {
|
|
53
|
+
if (!observer.onEvent) return
|
|
54
|
+
try {
|
|
55
|
+
// Normalize OUTSIDE the EventManager's own try/catch (which guards the
|
|
56
|
+
// listener, not the work the listener does), then hand off.
|
|
57
|
+
observer.onEvent(
|
|
58
|
+
normalizeEvent(name, data, getCorrelationId(), now())
|
|
59
|
+
)
|
|
60
|
+
} catch {
|
|
61
|
+
// Same: never let a bad observer break other listeners.
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return () => {
|
|
68
|
+
for (const unsubscribe of unsubscribers) unsubscribe()
|
|
69
|
+
unsubscribers.length = 0
|
|
70
|
+
}
|
|
71
|
+
}
|