@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
package/src/events.ts CHANGED
@@ -4,20 +4,36 @@ 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,
13
+ FailureKind,
14
+ ErrorSource,
15
+ LifecycleUnavailableReason
8
16
  } from '@meshconnect/uwc-types'
9
17
 
10
18
  /**
11
- * User-initiated operations that can surface through the `error` event.
19
+ * User-initiated operations that can surface through the `error` event. The
20
+ * runtime tuple is the single source of truth; the type is derived from it so a
21
+ * guard test can enumerate every op — and catch a new one added without handoff
22
+ * instrumentation — instead of relying on a hand-maintained list.
12
23
  */
13
- export type UWCOperation =
14
- | 'connect'
15
- | 'disconnect'
16
- | 'switchNetwork'
17
- | 'signMessage'
18
- | 'sendTransaction'
19
- | 'getWalletCapabilities'
20
- | 'initialize'
24
+ export const UWC_OPERATIONS = [
25
+ 'connect',
26
+ 'disconnect',
27
+ 'switchNetwork',
28
+ 'signMessage',
29
+ 'signTypedData',
30
+ 'sendTransaction',
31
+ 'signSolanaTransaction',
32
+ 'getWalletCapabilities',
33
+ 'initialize'
34
+ ] as const
35
+
36
+ export type UWCOperation = (typeof UWC_OPERATIONS)[number]
21
37
 
22
38
  /**
23
39
  * Typed event map emitted by UniversalWalletConnector.
@@ -31,8 +47,52 @@ export type UWCOperation =
31
47
  export interface UWCEventMap {
32
48
  /** Wallet detection finished; `getWallets()` now reflects which are installed. */
33
49
  ready: void
34
- /** Emitted once detection finishes, with the final wallet list. */
35
- walletsDetected: { wallets: WalletMetadata[] }
50
+ /**
51
+ * Emitted once detection finishes, with the final wallet list. `discovery` is a
52
+ * per-namespace requested-vs-matched summary (telemetry-only, non-PII counts) so
53
+ * "we asked for N solana wallets, matched 0" is distinguishable from "asked for
54
+ * none" — answers "why didn't wallet X appear?".
55
+ */
56
+ walletsDetected: {
57
+ wallets: WalletMetadata[]
58
+ discovery?:
59
+ | Record<string, { requested: number; matched: number }>
60
+ | undefined
61
+ }
62
+ /**
63
+ * A single namespace × connector discovery task threw. Telemetry-only — replaces
64
+ * the historical silent catch so a discovery failure is observable rather than
65
+ * vanishing. Carries the namespace + connection mode + scrubbed error; never an
66
+ * address or payload.
67
+ */
68
+ walletDiscoveryFailed: {
69
+ namespace: Namespace
70
+ connectionMode: ConnectionMode
71
+ error: WalletError
72
+ }
73
+ /**
74
+ * A bridge operation failed — e.g. wallet-discovery marshaling across the iframe
75
+ * Comlink boundary timed out or threw. Telemetry-only; replaces silent catches in
76
+ * `BridgeChild` so "the wallet never showed up via the bridge" (the Bitstarz
77
+ * class) is observable. Carries the namespace + scrubbed error; no address /
78
+ * payload. Deduped + benign poll-misses excluded at the source.
79
+ */
80
+ bridgeError: {
81
+ namespace?: Namespace | undefined
82
+ error: WalletError
83
+ }
84
+
85
+ /**
86
+ * The WalletConnect relay socket surfaced a transport/provider error — e.g. an
87
+ * abnormal close (code 3000 "JWT Token is not yet valid" when the device clock is
88
+ * skewed past the relay's 120s leeway). Emitted from the relayer's error event,
89
+ * deduped at the connector. This failure is detached from the connect() promise,
90
+ * so without it the error escapes the handoff funnel entirely. Telemetry-only;
91
+ * carries only a scrubbed message (no address / payload).
92
+ */
93
+ relayError: {
94
+ error: WalletError
95
+ }
36
96
 
37
97
  /** `connect()` has started. */
38
98
  connecting: { connectionMode: ConnectionMode; walletId: string }
@@ -57,8 +117,197 @@ export interface UWCEventMap {
57
117
  /** Wallet capabilities were refreshed. */
58
118
  capabilitiesUpdated: { capabilities: Record<string, EVMCapabilities> }
59
119
 
60
- /** A user-initiated operation failed. */
61
- error: { error: WalletError; operation: UWCOperation }
120
+ /**
121
+ * A user-initiated operation failed. Carries optional, telemetry-only
122
+ * classification (`errorCode` / `failureKind` / `source`) and, for a
123
+ * no-common-network connect failure, the two CAIP-2 id lists — so a PRE-wallet
124
+ * validation failure (which never enters the handoff funnel) is still
125
+ * diagnosable. The React-facing payload is still `{ error, operation }`; the
126
+ * rest is `undefined` for ordinary errors.
127
+ */
128
+ error: {
129
+ error: WalletError
130
+ operation: UWCOperation
131
+ errorCode?: string | undefined
132
+ failureKind?: FailureKind | undefined
133
+ source?: ErrorSource | undefined
134
+ networkMismatch?:
135
+ | { walletNetworkIds: string[]; configuredNetworkIds: string[] }
136
+ | undefined
137
+ /**
138
+ * `true` when a handoff terminal (`walletRejected`/`walletFailed`) already
139
+ * reported this same failure — this record is the back-compat duplicate.
140
+ * For failure metrics: count terminals + `error` where NOT handoffCovered;
141
+ * absent on pre-wallet validation failures (which have no terminal).
142
+ */
143
+ handoffCovered?: boolean | undefined
144
+ }
145
+
146
+ // ---- Observability (telemetry-only; see TELEMETRY_ONLY_EVENTS) -------------
147
+ // These describe what happened, not a state change, so they intentionally do
148
+ // NOT cascade to the legacy `change` channel (which drives React re-renders).
149
+
150
+ /**
151
+ * A line emitted through the internal `Logger`. Fires for ALL levels regardless
152
+ * of the console threshold so an observer can capture `debug`/`info` even when
153
+ * the console is quiet. `args` is scrubbed before reaching `observer.onLog`.
154
+ */
155
+ log: { level: LogLevel; message: string; args: unknown[] }
156
+
157
+ /**
158
+ * Emitted immediately BEFORE control is handed to the wallet for a boundary op
159
+ * (the moment the blind window opens). Pair with exactly one terminal event via
160
+ * `handoffId`. Carries no signing payload and no address — only routing facets.
161
+ */
162
+ awaitingWallet: {
163
+ operation: UWCOperation
164
+ connectionMode: ConnectionMode
165
+ walletId?: string | undefined
166
+ /** CAIP namespace (eip155 | solana | tron | tvm …) — not an address. */
167
+ namespace?: Namespace | undefined
168
+ /** CAIP chain id (e.g. `eip155:1`) — not an address. */
169
+ chainId?: NetworkId | undefined
170
+ handoffId: string
171
+ /** Derived, best-effort flow taxonomy (heuristic — see deriveWalletFlowType). */
172
+ walletFlowType: WalletFlowType
173
+ /** Coarse runtime surface (heuristic — see derivePlatform). */
174
+ platform: Platform
175
+ /** switchNetwork only: target chain already the active one (no-op switch). */
176
+ targetAlreadyActive?: boolean | undefined
177
+ }
178
+
179
+ /** The wallet resolved the op successfully. Closes a handoff. */
180
+ walletSucceeded: {
181
+ handoffId: string
182
+ operation: UWCOperation
183
+ durationMs: number
184
+ connectionMode: ConnectionMode
185
+ walletId?: string | undefined
186
+ namespace?: Namespace | undefined
187
+ chainId?: NetworkId | undefined
188
+ /** A `walletTimedOut` marker fired before this terminal (late completion). */
189
+ timedOut: boolean
190
+ /** The deadline that elapsed, present only when `timedOut`. */
191
+ timeoutMs?: number | undefined
192
+ /** switchNetwork only: target chain already the active one (no-op switch). */
193
+ targetAlreadyActive?: boolean | undefined
194
+ }
195
+ /** The user explicitly rejected (4001 / WalletError.type === 'rejected'). Closes a handoff. */
196
+ walletRejected: {
197
+ handoffId: string
198
+ operation: UWCOperation
199
+ durationMs: number
200
+ connectionMode: ConnectionMode
201
+ walletId?: string | undefined
202
+ namespace?: Namespace | undefined
203
+ chainId?: NetworkId | undefined
204
+ timedOut: boolean
205
+ timeoutMs?: number | undefined
206
+ targetAlreadyActive?: boolean | undefined
207
+ /** Stringified error code (EIP-1193 numeric / string), when present. */
208
+ errorCode?: string | undefined
209
+ failureKind?: FailureKind | undefined
210
+ source?: ErrorSource | undefined
211
+ }
212
+ /** The op failed for any non-rejection reason. Closes a handoff. */
213
+ walletFailed: {
214
+ handoffId: string
215
+ operation: UWCOperation
216
+ durationMs: number
217
+ error: WalletError
218
+ connectionMode: ConnectionMode
219
+ walletId?: string | undefined
220
+ namespace?: Namespace | undefined
221
+ chainId?: NetworkId | undefined
222
+ timedOut: boolean
223
+ timeoutMs?: number | undefined
224
+ targetAlreadyActive?: boolean | undefined
225
+ /** Stringified error code (EIP-1193 numeric / string), when present. */
226
+ errorCode?: string | undefined
227
+ failureKind?: FailureKind | undefined
228
+ source?: ErrorSource | undefined
229
+ }
230
+ /**
231
+ * Side marker: the deadline passed with the op still pending. OBSERVATIONAL —
232
+ * it does NOT reject, abort, or mutate state; the wallet may still complete and
233
+ * produce the real terminal. Never fires for an op that settled before the
234
+ * deadline. Not itself a terminal.
235
+ */
236
+ walletTimedOut: {
237
+ handoffId: string
238
+ operation: UWCOperation
239
+ durationMs: number
240
+ connectionMode: ConnectionMode
241
+ walletId?: string | undefined
242
+ namespace?: Namespace | undefined
243
+ chainId?: NetworkId | undefined
244
+ /** The deadline that elapsed (the configured marker timeout). */
245
+ timeoutMs: number
246
+ /** switchNetwork only: target chain already the active one (no-op switch). */
247
+ targetAlreadyActive?: boolean | undefined
248
+ }
249
+
250
+ // ---- Provider lifecycle (telemetry-only) ----------------------------------
251
+ // Post-connect changes the wallet reports on its own (the user switched account
252
+ // or chain IN the wallet, the wallet disconnected, a session expired). Address
253
+ // is DROPPED at the connector boundary; these carry routing facets only.
254
+
255
+ /** The wallet's active account/list changed. Signals THAT it changed, not which. */
256
+ walletAccountChanged: {
257
+ connectionMode: ConnectionMode
258
+ walletId?: string | undefined
259
+ namespace?: Namespace | undefined
260
+ }
261
+ /**
262
+ * The wallet switched chain on its own. Deduped against the echo of a
263
+ * UWC-initiated `switchNetwork` (which already surfaces via `networkSwitched`).
264
+ */
265
+ walletChainChanged: {
266
+ connectionMode: ConnectionMode
267
+ walletId?: string | undefined
268
+ namespace?: Namespace | undefined
269
+ chainId?: NetworkId | undefined
270
+ }
271
+ /** The wallet disconnected the session from its side. */
272
+ walletDisconnected: {
273
+ connectionMode: ConnectionMode
274
+ walletId?: string | undefined
275
+ namespace?: Namespace | undefined
276
+ }
277
+ /** A WalletConnect / TON session expired. */
278
+ walletSessionExpired: {
279
+ connectionMode: ConnectionMode
280
+ walletId?: string | undefined
281
+ namespace?: Namespace | undefined
282
+ }
283
+ /**
284
+ * Lifecycle telemetry could not be attached for this connection — emitted once
285
+ * so analytics can tell "genuinely silent" from "never wired". Carries WHY +
286
+ * whether the runtime is the direct page or the production iframe.
287
+ */
288
+ lifecycleTelemetryUnavailable: {
289
+ reason: LifecycleUnavailableReason
290
+ connectionMode: ConnectionMode
291
+ walletId?: string | undefined
292
+ namespace?: Namespace | undefined
293
+ runtime: 'direct' | 'iframe'
294
+ }
295
+
296
+ /**
297
+ * The page was backgrounded (tab hidden / app-switched) while a wallet handoff
298
+ * was still pending. Telemetry-only. On mobile this usually means the user
299
+ * left for the wallet app — a deep-link round-trip in progress, not an
300
+ * abandonment. UWC emits the raw FACT only; never an interpreted "abandoned".
301
+ */
302
+ pageHiddenDuringHandoff: { handoffId: string; platform: Platform }
303
+ /**
304
+ * The page became visible again (returned from the wallet / bfcache restore)
305
+ * while a handoff was still pending. Pairs with `pageHiddenDuringHandoff` to
306
+ * bracket an app-switch — consecutive same-state transitions are deduped per
307
+ * handoff (a bfcache restore can fire both `pageshow` and `visibilitychange`
308
+ * for one physical return). Telemetry-only.
309
+ */
310
+ pageVisibleDuringHandoff: { handoffId: string; platform: Platform }
62
311
 
63
312
  /**
64
313
  * Back-compat catch-all. Fires after every other event above.
@@ -67,7 +316,70 @@ export interface UWCEventMap {
67
316
  change: void
68
317
  }
69
318
 
319
+ /**
320
+ * Events that describe telemetry rather than a state change. `EventManager.emit`
321
+ * skips the legacy `change` cascade for these so high-frequency logging / handoff
322
+ * instrumentation can't trigger React re-renders through `subscribe()`.
323
+ */
324
+ export const TELEMETRY_ONLY_EVENTS: ReadonlySet<UWCEventName> = new Set([
325
+ 'log',
326
+ 'awaitingWallet',
327
+ 'walletSucceeded',
328
+ 'walletRejected',
329
+ 'walletFailed',
330
+ 'walletTimedOut',
331
+ 'walletDiscoveryFailed',
332
+ 'bridgeError',
333
+ 'relayError',
334
+ 'walletAccountChanged',
335
+ 'walletChainChanged',
336
+ 'walletDisconnected',
337
+ 'walletSessionExpired',
338
+ 'lifecycleTelemetryUnavailable',
339
+ 'pageHiddenDuringHandoff',
340
+ 'pageVisibleDuringHandoff'
341
+ ])
342
+
70
343
  export type UWCEventName = keyof UWCEventMap
71
344
  export type UWCEventListener<K extends UWCEventName> = (
72
345
  data: UWCEventMap[K]
73
346
  ) => void
347
+
348
+ /**
349
+ * Runtime list of every event name. The `Record<UWCEventName, true>` literal
350
+ * forces this to stay exhaustive — adding an event to `UWCEventMap` without
351
+ * listing it here is a compile error — so the observer bridge can never silently
352
+ * drop a new event.
353
+ */
354
+ const EVENT_NAME_FLAGS: Record<UWCEventName, true> = {
355
+ ready: true,
356
+ walletsDetected: true,
357
+ walletDiscoveryFailed: true,
358
+ bridgeError: true,
359
+ relayError: true,
360
+ connecting: true,
361
+ connected: true,
362
+ disconnected: true,
363
+ connectionUri: true,
364
+ sessionChanged: true,
365
+ networkSwitching: true,
366
+ networkSwitched: true,
367
+ capabilitiesUpdated: true,
368
+ error: true,
369
+ log: true,
370
+ awaitingWallet: true,
371
+ walletSucceeded: true,
372
+ walletRejected: true,
373
+ walletFailed: true,
374
+ walletTimedOut: true,
375
+ walletAccountChanged: true,
376
+ walletChainChanged: true,
377
+ walletDisconnected: true,
378
+ walletSessionExpired: true,
379
+ lifecycleTelemetryUnavailable: true,
380
+ pageHiddenDuringHandoff: true,
381
+ pageVisibleDuringHandoff: true,
382
+ change: true
383
+ }
384
+
385
+ export const UWC_EVENT_NAMES = Object.keys(EVENT_NAME_FLAGS) as UWCEventName[]
package/src/index.ts CHANGED
@@ -9,4 +9,14 @@ 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 {
18
+ TELEMETRY_SCHEMA_VERSION,
19
+ type UWCObserver,
20
+ type UWCTelemetryEvent
21
+ } from './observability/telemetry'
12
22
  export { toBaseUnits } from './utils/to-base-units'
@@ -0,0 +1,126 @@
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('ignores an inherited-key UWC_DEBUG override (e.g. "toString") — gating stays intact', () => {
100
+ const sink = makeSink()
101
+ const log = createLogger({ level: 'warn', sink })
102
+ // `'toString' in LEVEL_WEIGHT` is true via the prototype chain; treating it
103
+ // as a level would compute Math.min(fn, number) = NaN and hold the gate
104
+ // open for EVERY level. Garbage input must stay inert instead.
105
+ ;(globalThis as { UWC_DEBUG?: unknown }).UWC_DEBUG = 'toString'
106
+ log.debug('hidden')
107
+ log.info('hidden')
108
+ expect(sink.calls.debug).toHaveLength(0)
109
+ expect(sink.calls.info).toHaveLength(0)
110
+ log.warn('shown')
111
+ expect(sink.calls.warn).toHaveLength(1)
112
+ })
113
+
114
+ it('swallows a throwing onLog so the log call never breaks', () => {
115
+ const sink = makeSink()
116
+ const log = createLogger({
117
+ level: 'warn',
118
+ sink,
119
+ onLog: () => {
120
+ throw new Error('sink boom')
121
+ }
122
+ })
123
+ expect(() => log.warn('ok')).not.toThrow()
124
+ expect(sink.calls.warn).toHaveLength(1)
125
+ })
126
+ })
@@ -0,0 +1,112 @@
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
+ // Own-property check, not `in`: the prototype chain would accept keys like
54
+ // 'toString', whose "weight" is a function → NaN → the gate never closes.
55
+ if (
56
+ typeof override === 'string' &&
57
+ Object.prototype.hasOwnProperty.call(LEVEL_WEIGHT, override)
58
+ ) {
59
+ return Math.min(
60
+ LEVEL_WEIGHT[override as LogLevel],
61
+ LEVEL_WEIGHT[configured]
62
+ )
63
+ }
64
+ }
65
+ return LEVEL_WEIGHT[configured]
66
+ }
67
+
68
+ /**
69
+ * Console-backed, level-gated logger. ~zero-dep. Two seams:
70
+ * - `onLog` fires pre-gate (all levels) → drives the observer.
71
+ * - the console sink fires only at/above the threshold.
72
+ */
73
+ export function createLogger(options: CreateLoggerOptions = {}): ManagedLogger {
74
+ let configured: LogLevel = options.level ?? 'warn'
75
+ const customSink = options.sink
76
+
77
+ const emit = (level: LogLevel, message: string, args: unknown[]): void => {
78
+ // Pre-gate tap first — observability must not depend on the console threshold.
79
+ if (options.onLog) {
80
+ try {
81
+ options.onLog(level, message, args)
82
+ } catch {
83
+ // A failing sink must never break the caller's log statement.
84
+ }
85
+ }
86
+ if (LEVEL_WEIGHT[level] < resolveWeight(configured)) return
87
+ // Same contract as the `onLog` tap above: the sink (a consumer logger or the
88
+ // console) must never break the caller's log statement if it throws.
89
+ try {
90
+ if (customSink) {
91
+ customSink[level](message, ...args)
92
+ } else {
93
+ const line = `[${new Date().toISOString()}] ${level.toUpperCase()}: ${message}`
94
+ // eslint-disable-next-line no-console -- this IS the default console sink
95
+ console[level](line, ...args)
96
+ }
97
+ } catch {
98
+ // Swallowed by design — a broken sink is not the caller's problem.
99
+ }
100
+ }
101
+
102
+ return {
103
+ debug: (message, ...args) => emit('debug', message, args),
104
+ info: (message, ...args) => emit('info', message, args),
105
+ warn: (message, ...args) => emit('warn', message, args),
106
+ error: (message, ...args) => emit('error', message, args),
107
+ setLevel: level => {
108
+ configured = level
109
+ },
110
+ getLevel: () => configured
111
+ }
112
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest'
2
2
  import { EventManager } from './event-manager'
3
+ import { TELEMETRY_ONLY_EVENTS, UWC_EVENT_NAMES } from '../events'
3
4
 
4
5
  describe('EventManager', () => {
5
6
  let eventManager: EventManager
@@ -140,5 +141,59 @@ describe('EventManager', () => {
140
141
  eventManager.emit('change', undefined)
141
142
  expect(count).toBe(1)
142
143
  })
144
+
145
+ it('telemetry-only events do NOT cascade to the legacy change channel', () => {
146
+ let changeCount = 0
147
+ eventManager.subscribe(() => {
148
+ changeCount++
149
+ })
150
+ eventManager.emit('log', { level: 'debug', message: 'x', args: [] })
151
+ eventManager.emit('walletTimedOut', {
152
+ handoffId: 'h',
153
+ operation: 'signMessage',
154
+ durationMs: 1,
155
+ connectionMode: 'injected',
156
+ timeoutMs: 1000
157
+ })
158
+ // Neither should have triggered a re-render via `change`.
159
+ expect(changeCount).toBe(0)
160
+ })
161
+
162
+ it('state-change events still cascade to change alongside telemetry events', () => {
163
+ let changeCount = 0
164
+ eventManager.subscribe(() => {
165
+ changeCount++
166
+ })
167
+ eventManager.emit('log', { level: 'debug', message: 'x', args: [] })
168
+ eventManager.emit('disconnected', undefined)
169
+ expect(changeCount).toBe(1)
170
+ })
171
+
172
+ // Classification guard: a new bus event must be CONSCIOUSLY categorized.
173
+ // `TELEMETRY_ONLY_EVENTS` is a hand-maintained Set (not compile-enforced like
174
+ // EVENT_NAME_FLAGS), so an event added to the map but forgotten there would
175
+ // silently cascade to `change` and trigger React re-renders. This partition
176
+ // fails on that omission — forcing the author to declare telemetry vs state.
177
+ it('every bus event is classified as telemetry-only or state-change', () => {
178
+ const stateChangeEvents = [
179
+ 'ready',
180
+ 'walletsDetected',
181
+ 'connecting',
182
+ 'connected',
183
+ 'disconnected',
184
+ 'connectionUri',
185
+ 'sessionChanged',
186
+ 'networkSwitching',
187
+ 'networkSwitched',
188
+ 'capabilitiesUpdated',
189
+ 'error'
190
+ ]
191
+ const classified = new Set<string>([
192
+ ...TELEMETRY_ONLY_EVENTS,
193
+ ...stateChangeEvents,
194
+ 'change'
195
+ ])
196
+ expect([...classified].sort()).toEqual([...UWC_EVENT_NAMES].sort())
197
+ })
143
198
  })
144
199
  })