@icedevml/react-native-host-card-emulation 0.9.2 → 1.0.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.
@@ -1,152 +1,70 @@
1
+ /**
2
+ * Raw native module specification.
3
+ *
4
+ * NOTE: This is an internal API, don't use it directly. Use the `HCE` object exported from
5
+ * the package's entry point instead, it guarantees that every C-APDU is responded to exactly once.
6
+ */
7
+
1
8
  import type {CodegenTypes, TurboModule} from 'react-native';
2
9
  import {TurboModuleRegistry} from 'react-native';
3
10
 
4
- export type HCEModuleEventType =
5
- 'sessionStarted'
6
- | 'sessionInvalidated'
7
- /**
8
- * iOS: Whether the reader field was detected.
9
- * Android: Whether the reader had sent the select command to us.
10
- */
11
- | 'readerDetected'
12
- /**
13
- * Whether our HCE app was deselected or the reader was lost.
14
- */
15
- | 'readerDeselected'
16
- /**
17
- * Received C-APDU (hex-encoded string in "arg" key) from the reader.
18
- * Respond by calling respondAPDU(rapdu).
19
- */
20
- | 'received'
21
- /**
22
- * The device lock state, always reported before the 'received' event of the
23
- * first C-APDU of a session and then again whenever it changes.
24
- *
25
- * iOS: Always 'deviceUnlocked' - it's impossible to communicate over HCE while
26
- * the device is locked, so the state cannot change during a session.
27
- * Android: The state is sampled whenever a C-APDU arrives, so a change is
28
- * reported straight before the 'received' event of the C-APDU during which it
29
- * was noticed.
30
- */
31
- | 'deviceLocked'
32
- | 'deviceUnlocked';
33
- export type HCEModuleStopReason = 'success' | 'failure';
34
-
35
- export type HCEModuleBackgroundEventType =
36
- 'readerDeselected'
37
- | 'received'
38
- /**
39
- * The device lock state, always reported before the 'received' event of the
40
- * first C-APDU of a session and then again whenever it changes. The state is
41
- * sampled whenever a C-APDU arrives, so a change is reported straight before
42
- * the 'received' event of the C-APDU during which it was noticed.
43
- */
44
- | 'deviceLocked'
45
- | 'deviceUnlocked';
46
-
47
- export type HCEModuleEvent = {
48
- type: HCEModuleEventType
49
- arg: string | null,
50
- }
51
-
52
- export type HCEModuleBackgroundEvent = {
53
- type: HCEModuleBackgroundEventType
54
- arg: string | null,
55
- audience: string,
56
- }
57
-
58
- export interface Spec extends TurboModule {
59
- /**
60
- * Checks if a given smartphone supports required HCE features.
61
- */
62
- isPlatformSupported(): boolean;
63
-
11
+ export type NativeHCEEvent = {
64
12
  /**
65
- * iOS: Tries to acquire NFCPresentmentIntentAssertion.
66
- * Android: Not supported (throws an exception).
13
+ * Foreground session ID (generated by JS) or background session ID (generated by native code).
67
14
  */
68
- acquireExclusiveNFC(): Promise<void>;
69
- /**
70
- * iOS: Release NFCPresentmentIntentAssertion object.
71
- * Android: Not supported (no-op).
72
- */
73
- releaseExclusiveNFC(): void;
15
+ sessionId: string;
74
16
  /**
75
- * iOS: Whether the NFCPresentmentIntentAssertion is acquired and still valid.
76
- * Android: Always false.
17
+ * One of: 'sessionStarted', 'readerDetected', 'readerLost', 'apdu', 'sessionInvalidated'.
77
18
  */
78
- isExclusiveNFC(): boolean;
79
-
19
+ type: string;
80
20
  /**
81
- * Begin card operation session.
82
- * iOS: Create CardSession.
83
- * Android: Sets an internal flag that the session is open.
21
+ * 'apdu' only: identifier that has to be passed back to respondApdu(), otherwise 0.
84
22
  */
85
- beginSession(): Promise<void>;
23
+ apduId: number;
86
24
  /**
87
- * iOS: Set alert message on the NFC system UI.
88
- * Android: NO-OP.
25
+ * 'apdu' only: hex-encoded C-APDU, otherwise empty string.
89
26
  */
90
- setSessionAlertMessage(message: string): void;
27
+ command: string;
91
28
  /**
92
- * iOS: Invalidate CardSession.
93
- * Android: Set an internal flag that the session is closed and disable HCE service.
29
+ * 'apdu' only: whether the device was locked when the C-APDU was received, otherwise false.
94
30
  */
95
- invalidateSession(): void;
31
+ deviceLocked: boolean;
96
32
  /**
97
- * iOS: Checks if CardSession is running.
98
- * Android: Checks if an internal flag is set.
33
+ * 'sessionInvalidated' only: the reason of invalidation, otherwise empty string.
99
34
  */
100
- isSessionRunning(): boolean;
35
+ reason: string;
36
+ };
101
37
 
102
- /**
103
- * iOS: Start the card emulation.
104
- * Android: Enable HCE service.
105
- */
106
- startHCE(): Promise<void>;
107
- /**
108
- * iOS: Stop the card emulation with "success"/"failure" UI status.
109
- * Android: Disable HCE service.
110
- */
111
- stopHCE(status: HCEModuleStopReason): Promise<void>;
112
- /**
113
- * Send R-APDU (hex-encoded string) as a response to the last received C-APDU event.
114
- * Set handle=null when calling this function from foreground app.
115
- */
116
- respondAPDU(handle: string | null, rapdu: string): Promise<void>;
117
- /**
118
- * iOS: Checks if the card emulation is running.
119
- * Android: Checks if the HCE service is enabled.
120
- */
121
- isHCERunning(): Promise<boolean>;
38
+ export type NativeHCECapabilities = {
39
+ platform: string;
40
+ hceSupported: boolean;
41
+ nfcEnabled: boolean;
42
+ eligible: boolean;
43
+ backgroundSupported: boolean;
44
+ presentmentSupported: boolean;
45
+ };
122
46
 
123
- /**
124
- * Android: Report that the JS headless task is ready to handle HCE interaction in the background.
125
- * NOTE: Don't call this API directly, use wrapper hceBackground.ts:createBackgroundHCE.
126
- * See demo app or README.md for example wrapper usage.
127
- */
128
- beginBackgroundHCE(handle: string): boolean;
47
+ export interface Spec extends TurboModule {
48
+ getCapabilities(): Promise<NativeHCECapabilities>;
129
49
 
130
- /**
131
- * Android: Report the background HCE interaction was finished.
132
- * NOTE: Don't call this API directly, use wrapper hceBackground.ts:createBackgroundHCE.
133
- * See demo app or README.md for example wrapper usage.
134
- */
135
- finishBackgroundHCE(handle: string): boolean;
50
+ beginSession(sessionId: string, allowWhenLocked: boolean): Promise<void>;
51
+ invalidateSession(sessionId: string): void;
52
+ setSessionAlertMessage(sessionId: string, message: string): void;
136
53
 
137
- /**
138
- * Event handler for foreground HCE interactions.
139
- */
140
- readonly onEvent: CodegenTypes.EventEmitter<HCEModuleEvent>;
54
+ startEmulation(sessionId: string): Promise<void>;
55
+ stopEmulation(sessionId: string, status: string): Promise<void>;
56
+ isEmulationRunning(sessionId: string): Promise<boolean>;
141
57
 
142
- /**
143
- * Event handler for background HCE interactions.
144
- * NOTE: Don't subscribe to that handler directly, use wrapper hceBackground.ts:createBackgroundHCE.
145
- * See demo app or README.md for example wrapper usage.
146
- */
147
- readonly onBackgroundEvent: CodegenTypes.EventEmitter<HCEModuleBackgroundEvent>;
58
+ respondApdu(sessionId: string, apduId: number, rapdu: string): Promise<void>;
59
+
60
+ acquireExclusiveNFC(): Promise<void>;
61
+ releaseExclusiveNFC(): void;
62
+ isExclusiveNFC(): boolean;
63
+
64
+ setBackgroundHandlerRegistered(registered: boolean, allowWhenLocked: boolean): void;
65
+ backgroundSessionReady(sessionId: string): boolean;
66
+
67
+ readonly onEvent: CodegenTypes.EventEmitter<NativeHCEEvent>;
148
68
  }
149
69
 
150
- export default TurboModuleRegistry.getEnforcing<Spec>(
151
- 'NativeHCEModule',
152
- );
70
+ export default TurboModuleRegistry.getEnforcing<Spec>('NativeHCEModule');
@@ -0,0 +1,168 @@
1
+ import {AppRegistry, Platform} from 'react-native';
2
+
3
+ import NativeHCEModule from './NativeHCEModule';
4
+ import {
5
+ addSessionListener,
6
+ callHandler,
7
+ HCEError,
8
+ processApdu,
9
+ reportError,
10
+ resolveHandlerOptions,
11
+ SerialQueue,
12
+ } from './internal';
13
+ import type {
14
+ BackgroundAppState,
15
+ BackgroundHandlerFactory,
16
+ BackgroundHandlerOptions,
17
+ BackgroundHandlers,
18
+ } from './types';
19
+
20
+ // must match HCEManager.BACKGROUND_TASK_KEY on the native side
21
+ const BACKGROUND_TASK_KEY = 'handleBackgroundHCECall';
22
+
23
+ interface BackgroundRegistration {
24
+ factory: BackgroundHandlerFactory;
25
+ handlerTimeout: number;
26
+ }
27
+
28
+ let currentRegistration: BackgroundRegistration | null = null;
29
+ let taskRegistered = false;
30
+
31
+ if (Platform.OS === 'android') {
32
+ /*
33
+ Until the JS side declares whether it has a background handler, the native side holds the
34
+ interactions instead of rejecting them, as React Native might still be evaluating the bundle.
35
+ The handler is registered at the top level of the entry file, so the timer fires after that.
36
+ */
37
+ setTimeout(() => {
38
+ if (!taskRegistered) {
39
+ NativeHCEModule.setBackgroundHandlerRegistered(false, true);
40
+ }
41
+ }, 0);
42
+ }
43
+
44
+ interface BackgroundTaskData {
45
+ sessionId?: string;
46
+ appState?: string;
47
+ }
48
+
49
+ async function runBackgroundTask(data: BackgroundTaskData): Promise<void> {
50
+ const sessionId = data?.sessionId;
51
+ const registration = currentRegistration;
52
+
53
+ if (!sessionId || !registration) {
54
+ // finishing the task makes the native side reject the C-APDUs of this interaction
55
+ return;
56
+ }
57
+
58
+ const {factory, handlerTimeout} = registration;
59
+ const appState: BackgroundAppState = data.appState === 'foreground' ? 'foreground' : 'background';
60
+ const queue = new SerialQueue();
61
+ let handlers: BackgroundHandlers | null = null;
62
+
63
+ /*
64
+ The task lives until the reader is lost. Once it resolves, React Native finishes the headless
65
+ task and the native side stops routing C-APDUs of this interaction to JS.
66
+ */
67
+ await new Promise<void>(resolve => {
68
+ let finished = false;
69
+ let removeListener = () => {};
70
+
71
+ const finish = () => {
72
+ if (!finished) {
73
+ finished = true;
74
+ removeListener();
75
+ resolve();
76
+ }
77
+ };
78
+
79
+ removeListener = addSessionListener(sessionId, event => {
80
+ const h = handlers;
81
+
82
+ if (!h || finished) {
83
+ return;
84
+ }
85
+
86
+ if (event.type === 'apdu') {
87
+ queue.push(() => processApdu(sessionId, event, h.onApdu, h.onError, handlerTimeout));
88
+ } else if (event.type === 'readerLost') {
89
+ const onReaderLost = h.onReaderLost;
90
+ queue
91
+ .push(() => callHandler('onReaderLost()', () => onReaderLost?.(), handlerTimeout))
92
+ .catch(e => reportError(h.onError, e))
93
+ .finally(finish);
94
+ }
95
+ });
96
+
97
+ (async () => {
98
+ try {
99
+ const created = await callHandler(
100
+ 'The background handler factory',
101
+ () => factory({sessionId, appState}),
102
+ handlerTimeout,
103
+ );
104
+
105
+ if (!created || typeof created.onApdu !== 'function') {
106
+ throw new HCEError('err_invalid_argument', 'The background handler factory must return an object with onApdu().');
107
+ }
108
+
109
+ handlers = created;
110
+ } catch (e) {
111
+ reportError(undefined, e);
112
+ finish();
113
+ return;
114
+ }
115
+
116
+ // the native side starts delivering events (including the queued C-APDU) from now on
117
+ if (!NativeHCEModule.backgroundSessionReady(sessionId)) {
118
+ // the reader was already lost
119
+ finish();
120
+ }
121
+ })();
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Android: Register the handler of HCE interactions that happen while the app has no session
127
+ * with running emulation - i.e. the app is in background, not running at all or it's in foreground
128
+ * without an active session (check BackgroundContext.appState).
129
+ *
130
+ * Call it once, at the top level of the app's entry file (index.js), before AppRegistry.registerComponent().
131
+ * Calling it again replaces the previous factory and its options. No-op on iOS.
132
+ */
133
+ export function registerBackgroundHandler(
134
+ factory: BackgroundHandlerFactory,
135
+ options: BackgroundHandlerOptions = {},
136
+ ): void {
137
+ if (Platform.OS !== 'android') {
138
+ return;
139
+ }
140
+
141
+ if (typeof factory !== 'function') {
142
+ throw new HCEError('err_invalid_argument', 'The background handler factory must be a function.');
143
+ }
144
+
145
+ const {handlerTimeout, allowWhenLocked} = resolveHandlerOptions(options);
146
+ currentRegistration = {factory, handlerTimeout};
147
+
148
+ if (!taskRegistered) {
149
+ AppRegistry.registerHeadlessTask(BACKGROUND_TASK_KEY, () => runBackgroundTask);
150
+ taskRegistered = true;
151
+ }
152
+
153
+ NativeHCEModule.setBackgroundHandlerRegistered(true, allowWhenLocked);
154
+ }
155
+
156
+ /**
157
+ * Android: Stop handling interactions in background, until registerBackgroundHandler() is called again.
158
+ * The readers then receive an error status word unless there is a session with running emulation.
159
+ * Interactions that have already started are served until the reader is lost. No-op on iOS.
160
+ */
161
+ export function unregisterBackgroundHandler(): void {
162
+ if (Platform.OS !== 'android') {
163
+ return;
164
+ }
165
+
166
+ currentRegistration = null;
167
+ NativeHCEModule.setBackgroundHandlerRegistered(false, true);
168
+ }
package/js/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ import NativeHCEModule from './NativeHCEModule';
2
+ import {registerBackgroundHandler, unregisterBackgroundHandler} from './background';
3
+ import {bytesToHex, HCEError, hexToBytes} from './internal';
4
+ import {beginSession} from './session';
5
+ import type {HCECapabilities, HCEPlatform} from './types';
6
+
7
+ async function getCapabilities(): Promise<HCECapabilities> {
8
+ const caps = await NativeHCEModule.getCapabilities();
9
+
10
+ return {
11
+ platform: caps.platform as HCEPlatform,
12
+ hceSupported: caps.hceSupported,
13
+ nfcEnabled: caps.nfcEnabled,
14
+ eligible: caps.eligible,
15
+ backgroundSupported: caps.backgroundSupported,
16
+ presentmentSupported: caps.presentmentSupported,
17
+ };
18
+ }
19
+
20
+ export const HCE = {
21
+ /**
22
+ * Check what the device supports. Always check it before beginning a session.
23
+ */
24
+ getCapabilities,
25
+ beginSession,
26
+ registerBackgroundHandler,
27
+ unregisterBackgroundHandler,
28
+
29
+ /**
30
+ * iOS: Acquire NFCPresentmentIntentAssertion (exclusive NFC access for a limited time).
31
+ * Android: Not supported, rejects with err_platform_unsupported.
32
+ */
33
+ acquireExclusiveNFC: (): Promise<void> => NativeHCEModule.acquireExclusiveNFC(),
34
+ /**
35
+ * iOS: Release NFCPresentmentIntentAssertion.
36
+ * Android: No-op.
37
+ */
38
+ releaseExclusiveNFC: (): void => NativeHCEModule.releaseExclusiveNFC(),
39
+ /**
40
+ * iOS: Whether NFCPresentmentIntentAssertion is acquired and still valid.
41
+ * Android: Always false.
42
+ */
43
+ isExclusiveNFC: (): boolean => NativeHCEModule.isExclusiveNFC(),
44
+ };
45
+
46
+ export default HCE;
47
+
48
+ export {HCEError, bytesToHex, hexToBytes};
49
+ export * from './types';
package/js/internal.ts ADDED
@@ -0,0 +1,218 @@
1
+ import type {EventSubscription} from 'react-native';
2
+
3
+ import NativeHCEModule, {NativeHCEEvent} from './NativeHCEModule';
4
+ import type {ApduHandler, HandlerOptions, ResponseAPDU} from './types';
5
+
6
+ // ISO 7816-4: no precise diagnosis
7
+ const SW_NO_PRECISE_DIAGNOSIS = '6F00';
8
+ const HEX_CHARS = '0123456789ABCDEF';
9
+
10
+ const DEFAULT_HANDLER_TIMEOUT = 10000;
11
+ // setTimeout() fires immediately when the delay doesn't fit in a signed 32-bit integer
12
+ const MAX_HANDLER_TIMEOUT = 0x7fffffff;
13
+
14
+ export class HCEError extends Error {
15
+ readonly code: string;
16
+
17
+ constructor(code: string, message: string) {
18
+ super(message);
19
+ this.name = 'HCEError';
20
+ this.code = code;
21
+ }
22
+ }
23
+
24
+ export function bytesToHex(bytes: ResponseAPDU): string {
25
+ let out = '';
26
+
27
+ for (let i = 0; i < bytes.length; i++) {
28
+ const b = bytes[i];
29
+
30
+ if (!Number.isInteger(b) || b < 0 || b > 0xff) {
31
+ throw new HCEError('err_invalid_rapdu', `Invalid byte value at index ${i}: ${b}`);
32
+ }
33
+
34
+ out += HEX_CHARS[b >> 4] + HEX_CHARS[b & 0x0f];
35
+ }
36
+
37
+ return out;
38
+ }
39
+
40
+ export function hexToBytes(hex: string): Uint8Array {
41
+ if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) {
42
+ throw new HCEError('err_invalid_hex', 'Invalid hex string.');
43
+ }
44
+
45
+ const out = new Uint8Array(hex.length / 2);
46
+
47
+ for (let i = 0; i < out.length; i++) {
48
+ out[i] = parseInt(hex.substr(i * 2, 2), 16);
49
+ }
50
+
51
+ return out;
52
+ }
53
+
54
+ export function reportError(onError: ((error: unknown) => void) | undefined, error: unknown) {
55
+ if (onError) {
56
+ try {
57
+ onError(error);
58
+ return;
59
+ } catch (e) {
60
+ console.warn('[HCE] onError handler threw', e);
61
+ }
62
+ }
63
+
64
+ console.warn('[HCE] handler error', error);
65
+ }
66
+
67
+ export function isErrorCode(error: unknown, code: string): boolean {
68
+ return typeof error === 'object' && error !== null && (error as {code?: unknown}).code === code;
69
+ }
70
+
71
+ export interface ResolvedHandlerOptions {
72
+ // milliseconds, 0 stands for no timeout
73
+ handlerTimeout: number;
74
+ allowWhenLocked: boolean;
75
+ }
76
+
77
+ export function resolveHandlerOptions(options: HandlerOptions): ResolvedHandlerOptions {
78
+ const {handlerTimeout = DEFAULT_HANDLER_TIMEOUT, allowWhenLocked = true} = options;
79
+
80
+ if (
81
+ typeof handlerTimeout !== 'number' ||
82
+ !(handlerTimeout === Infinity || (handlerTimeout >= 0 && handlerTimeout <= MAX_HANDLER_TIMEOUT))
83
+ ) {
84
+ throw new HCEError(
85
+ 'err_invalid_argument',
86
+ `handlerTimeout must be a number of milliseconds between 0 and ${MAX_HANDLER_TIMEOUT} or Infinity.`,
87
+ );
88
+ }
89
+
90
+ if (typeof allowWhenLocked !== 'boolean') {
91
+ throw new HCEError('err_invalid_argument', 'allowWhenLocked must be a boolean.');
92
+ }
93
+
94
+ return {handlerTimeout: handlerTimeout === Infinity ? 0 : handlerTimeout, allowWhenLocked};
95
+ }
96
+
97
+ /**
98
+ * Call the handler and wait for its result, but at most `timeout` milliseconds (0 = no limit).
99
+ * The handler can't be cancelled, its late result (or error) is ignored.
100
+ */
101
+ export function callHandler<T>(name: string, handler: () => T | PromiseLike<T>, timeout: number): Promise<T> {
102
+ // also turns a synchronous exception into a rejection
103
+ const result = new Promise<T>(resolve => resolve(handler()));
104
+
105
+ if (timeout === 0) {
106
+ return result;
107
+ }
108
+
109
+ return new Promise<T>((resolve, reject) => {
110
+ const timer = setTimeout(() => {
111
+ reject(new HCEError('err_handler_timeout', `${name} has not finished within ${timeout} ms.`));
112
+ }, timeout);
113
+
114
+ result.then(
115
+ value => {
116
+ clearTimeout(timer);
117
+ resolve(value);
118
+ },
119
+ error => {
120
+ clearTimeout(timer);
121
+ reject(error);
122
+ },
123
+ );
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Runs the pushed tasks one after another, so that the event handlers never interleave.
129
+ */
130
+ export class SerialQueue {
131
+ private tail: Promise<void> = Promise.resolve();
132
+
133
+ push(task: () => unknown): Promise<void> {
134
+ const result = this.tail.then(async () => {
135
+ await task();
136
+ });
137
+ this.tail = result.catch(() => {});
138
+ return result;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Pass the C-APDU to the handler and always respond to it: with the handler's result
144
+ * or with an error status word if the handler has failed.
145
+ */
146
+ export async function processApdu(
147
+ sessionId: string,
148
+ event: NativeHCEEvent,
149
+ onApdu: ApduHandler,
150
+ onError: ((error: unknown) => void) | undefined,
151
+ timeout: number,
152
+ ): Promise<void> {
153
+ let response: string;
154
+
155
+ try {
156
+ const result = await callHandler(
157
+ 'onApdu()',
158
+ () =>
159
+ onApdu({
160
+ id: event.apduId,
161
+ command: hexToBytes(event.command),
162
+ deviceLocked: event.deviceLocked,
163
+ }),
164
+ timeout,
165
+ );
166
+
167
+ if (result == null || typeof result.length !== 'number' || result.length < 2) {
168
+ throw new HCEError('err_invalid_rapdu', 'onApdu() must return at least 2 bytes (the status word).');
169
+ }
170
+
171
+ response = bytesToHex(result);
172
+ } catch (e) {
173
+ reportError(onError, e);
174
+ response = SW_NO_PRECISE_DIAGNOSIS;
175
+ }
176
+
177
+ try {
178
+ await NativeHCEModule.respondApdu(sessionId, event.apduId, response);
179
+ } catch (e) {
180
+ // the reader was lost or the session was closed in the meantime, that's expected
181
+ if (!isErrorCode(e, 'err_stale_apdu')) {
182
+ reportError(onError, e);
183
+ }
184
+ }
185
+ }
186
+
187
+ type SessionListener = (event: NativeHCEEvent) => void;
188
+
189
+ const sessionListeners = new Map<string, SessionListener>();
190
+ let subscription: EventSubscription | null = null;
191
+
192
+ function dispatchEvent(event: NativeHCEEvent) {
193
+ const listener = sessionListeners.get(event.sessionId);
194
+
195
+ if (listener) {
196
+ listener(event);
197
+ } else if (event.type === 'apdu') {
198
+ // nobody is listening, don't leave the reader waiting
199
+ NativeHCEModule.respondApdu(event.sessionId, event.apduId, SW_NO_PRECISE_DIAGNOSIS).catch(() => {});
200
+ }
201
+ }
202
+
203
+ /**
204
+ * There is a single native event stream, the events are routed to listeners by session ID.
205
+ */
206
+ export function addSessionListener(sessionId: string, listener: SessionListener): () => void {
207
+ if (!subscription) {
208
+ subscription = NativeHCEModule.onEvent(dispatchEvent);
209
+ }
210
+
211
+ sessionListeners.set(sessionId, listener);
212
+
213
+ return () => {
214
+ if (sessionListeners.get(sessionId) === listener) {
215
+ sessionListeners.delete(sessionId);
216
+ }
217
+ };
218
+ }