@hsuite/native-connect-client 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.
Files changed (107) hide show
  1. package/README.md +51 -0
  2. package/coverage/base.css +224 -0
  3. package/coverage/block-navigation.js +87 -0
  4. package/coverage/coverage-summary.json +10 -0
  5. package/coverage/favicon.png +0 -0
  6. package/coverage/index.html +161 -0
  7. package/coverage/lcov-report/base.css +224 -0
  8. package/coverage/lcov-report/block-navigation.js +87 -0
  9. package/coverage/lcov-report/favicon.png +0 -0
  10. package/coverage/lcov-report/index.html +161 -0
  11. package/coverage/lcov-report/prettify.css +1 -0
  12. package/coverage/lcov-report/prettify.js +2 -0
  13. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  14. package/coverage/lcov-report/sorter.js +210 -0
  15. package/coverage/lcov-report/src/handlers/index.html +146 -0
  16. package/coverage/lcov-report/src/handlers/session-handler.ts.html +223 -0
  17. package/coverage/lcov-report/src/handlers/signing-handler.ts.html +217 -0
  18. package/coverage/lcov-report/src/handlers/wallet-handler.ts.html +193 -0
  19. package/coverage/lcov-report/src/harness/index.html +116 -0
  20. package/coverage/lcov-report/src/harness/signing-harness.ts.html +352 -0
  21. package/coverage/lcov-report/src/index.html +146 -0
  22. package/coverage/lcov-report/src/memory-transport.ts.html +358 -0
  23. package/coverage/lcov-report/src/protocol/e2e-harness.ts.html +568 -0
  24. package/coverage/lcov-report/src/protocol/index.html +116 -0
  25. package/coverage/lcov-report/src/query-client.ts.html +277 -0
  26. package/coverage/lcov-report/src/reference-client.ts.html +691 -0
  27. package/coverage/lcov.info +981 -0
  28. package/coverage/prettify.css +1 -0
  29. package/coverage/prettify.js +2 -0
  30. package/coverage/sort-arrow-sprite.png +0 -0
  31. package/coverage/sorter.js +210 -0
  32. package/coverage/src/handlers/index.html +146 -0
  33. package/coverage/src/handlers/session-handler.ts.html +223 -0
  34. package/coverage/src/handlers/signing-handler.ts.html +217 -0
  35. package/coverage/src/handlers/wallet-handler.ts.html +193 -0
  36. package/coverage/src/harness/index.html +116 -0
  37. package/coverage/src/harness/signing-harness.ts.html +352 -0
  38. package/coverage/src/index.html +146 -0
  39. package/coverage/src/memory-transport.ts.html +358 -0
  40. package/coverage/src/protocol/e2e-harness.ts.html +568 -0
  41. package/coverage/src/protocol/index.html +116 -0
  42. package/coverage/src/query-client.ts.html +277 -0
  43. package/coverage/src/reference-client.ts.html +691 -0
  44. package/dist/handlers/session-handler.d.ts +22 -0
  45. package/dist/handlers/session-handler.d.ts.map +1 -0
  46. package/dist/handlers/session-handler.js +41 -0
  47. package/dist/handlers/session-handler.js.map +1 -0
  48. package/dist/handlers/signing-handler.d.ts +22 -0
  49. package/dist/handlers/signing-handler.d.ts.map +1 -0
  50. package/dist/handlers/signing-handler.js +39 -0
  51. package/dist/handlers/signing-handler.js.map +1 -0
  52. package/dist/handlers/wallet-handler.d.ts +22 -0
  53. package/dist/handlers/wallet-handler.d.ts.map +1 -0
  54. package/dist/handlers/wallet-handler.js +32 -0
  55. package/dist/handlers/wallet-handler.js.map +1 -0
  56. package/dist/harness/signing-harness.d.ts +47 -0
  57. package/dist/harness/signing-harness.d.ts.map +1 -0
  58. package/dist/harness/signing-harness.js +79 -0
  59. package/dist/harness/signing-harness.js.map +1 -0
  60. package/dist/index.d.ts +24 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +24 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/memory-transport-options.d.ts +27 -0
  65. package/dist/memory-transport-options.d.ts.map +1 -0
  66. package/dist/memory-transport-options.js +11 -0
  67. package/dist/memory-transport-options.js.map +1 -0
  68. package/dist/memory-transport.d.ts +55 -0
  69. package/dist/memory-transport.d.ts.map +1 -0
  70. package/dist/memory-transport.js +81 -0
  71. package/dist/memory-transport.js.map +1 -0
  72. package/dist/protocol/e2e-harness.d.ts +65 -0
  73. package/dist/protocol/e2e-harness.d.ts.map +1 -0
  74. package/dist/protocol/e2e-harness.js +130 -0
  75. package/dist/protocol/e2e-harness.js.map +1 -0
  76. package/dist/protocol/index.d.ts +22 -0
  77. package/dist/protocol/index.d.ts.map +1 -0
  78. package/dist/protocol/index.js +22 -0
  79. package/dist/protocol/index.js.map +1 -0
  80. package/dist/query-client.d.ts +41 -0
  81. package/dist/query-client.d.ts.map +1 -0
  82. package/dist/query-client.js +52 -0
  83. package/dist/query-client.js.map +1 -0
  84. package/dist/reference-client.d.ts +100 -0
  85. package/dist/reference-client.d.ts.map +1 -0
  86. package/dist/reference-client.js +149 -0
  87. package/dist/reference-client.js.map +1 -0
  88. package/package.json +46 -0
  89. package/src/handlers/session-handler.ts +46 -0
  90. package/src/handlers/signing-handler.ts +44 -0
  91. package/src/handlers/wallet-handler.ts +36 -0
  92. package/src/harness/signing-harness.ts +89 -0
  93. package/src/index.ts +24 -0
  94. package/src/memory-transport-options.ts +28 -0
  95. package/src/memory-transport.ts +91 -0
  96. package/src/protocol/e2e-harness.ts +161 -0
  97. package/src/protocol/index.ts +23 -0
  98. package/src/query-client.ts +64 -0
  99. package/src/reference-client.spec.ts +408 -0
  100. package/src/reference-client.ts +202 -0
  101. package/tests/e2e-harness.spec.ts +64 -0
  102. package/tests/realnet-session.spec.ts +303 -0
  103. package/tests/signing-harness.spec.ts +41 -0
  104. package/tsconfig.build.json +10 -0
  105. package/tsconfig.build.tsbuildinfo +1 -0
  106. package/tsconfig.json +17 -0
  107. package/vitest.config.ts +35 -0
@@ -0,0 +1,408 @@
1
+ /**
2
+ * @fileoverview Unit tests for ReferenceClient
3
+ * Tests RPC client functionality for SDK integration testing
4
+ */
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
+ import { ReferenceClient, type ReferenceClientTransport } from './reference-client.js';
7
+ import type {
8
+ RpcEnvelope,
9
+ RpcRequest,
10
+ RpcResponse,
11
+ RpcSuccess,
12
+ } from '@hsuite/native-connect-types';
13
+ import { RpcVersion } from '@hsuite/native-connect-types';
14
+
15
+ /**
16
+ * @description Flushes the microtask queue to allow queued async work to start.
17
+ */
18
+ const flushMicrotask = () => new Promise<void>((resolve) => queueMicrotask(() => resolve()));
19
+
20
+ /**
21
+ * @description Ensures an RPC response is a success payload and narrows the type for result assertions.
22
+ * @throws Error when the response is an error variant.
23
+ */
24
+ function assertRpcSuccess<T>(response: RpcResponse<T>): asserts response is RpcSuccess<T> {
25
+ if ('error' in response) {
26
+ throw new Error(`Expected RPC success response but received error: ${response.error.message}`);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Create a mock transport for testing.
32
+ * The transport simulates responses in the format that RpcCodec expects.
33
+ */
34
+ function createMockTransport(): ReferenceClientTransport & {
35
+ listeners: Set<(message: RpcEnvelope) => void>;
36
+ serverListeners: Set<(message: RpcEnvelope) => void>;
37
+ sentMessages: RpcEnvelope[];
38
+ simulateResponse: (response: RpcResponse) => void;
39
+ simulateRequest: (request: RpcRequest) => void;
40
+ } {
41
+ const listeners = new Set<(message: RpcEnvelope) => void>();
42
+ const serverListeners = new Set<(message: RpcEnvelope) => void>();
43
+ const sentMessages: RpcEnvelope[] = [];
44
+
45
+ return {
46
+ listeners,
47
+ serverListeners,
48
+ sentMessages,
49
+ send: vi.fn(async (message: RpcEnvelope) => {
50
+ sentMessages.push(message);
51
+ }),
52
+ onMessage: (listener) => {
53
+ listeners.add(listener);
54
+ },
55
+ offMessage: (listener) => {
56
+ listeners.delete(listener);
57
+ },
58
+ onServerMessage: (listener) => {
59
+ serverListeners.add(listener);
60
+ },
61
+ offServerMessage: (listener) => {
62
+ serverListeners.delete(listener);
63
+ },
64
+ /**
65
+ * Simulate a response in the format RpcCodec expects.
66
+ * The envelope id must be a UUID, separate from the payload id.
67
+ */
68
+ simulateResponse: (response: RpcResponse) => {
69
+ // Create envelope in the same format as RpcCodec.encodeResponse()
70
+ // Important: envelope.id is a UUID, while payload.id is the RPC request ID
71
+ const envelope: RpcEnvelope = {
72
+ id: crypto.randomUUID(), // Envelope ID is always a UUID
73
+ topic: 'rpc',
74
+ version: RpcVersion,
75
+ createdAt: Date.now(),
76
+ payload: {
77
+ ...response,
78
+ jsonrpc: '2.0',
79
+ timestamp: Date.now(),
80
+ },
81
+ } as RpcEnvelope;
82
+ listeners.forEach((listener) => listener(envelope));
83
+ },
84
+ /**
85
+ * Simulate a request in the format RpcCodec expects
86
+ */
87
+ simulateRequest: (request: RpcRequest) => {
88
+ const envelope: RpcEnvelope = {
89
+ id: crypto.randomUUID(),
90
+ topic: 'rpc',
91
+ version: RpcVersion,
92
+ createdAt: Date.now(),
93
+ payload: {
94
+ ...request,
95
+ id: request.id ?? crypto.randomUUID(),
96
+ jsonrpc: '2.0',
97
+ timestamp: Date.now(),
98
+ },
99
+ } as RpcEnvelope;
100
+ listeners.forEach((listener) => listener(envelope));
101
+ },
102
+ };
103
+ }
104
+
105
+ describe('ReferenceClient', () => {
106
+ let transport: ReturnType<typeof createMockTransport>;
107
+ let client: ReferenceClient;
108
+
109
+ beforeEach(() => {
110
+ transport = createMockTransport();
111
+ client = new ReferenceClient({
112
+ transport,
113
+ timeoutMs: 1000,
114
+ });
115
+ });
116
+
117
+ afterEach(() => {
118
+ client.destroy();
119
+ vi.restoreAllMocks();
120
+ });
121
+
122
+ describe('constructor', () => {
123
+ it('should create client with default timeout', () => {
124
+ const defaultClient = new ReferenceClient({ transport });
125
+ expect(defaultClient).toBeDefined();
126
+ defaultClient.destroy();
127
+ });
128
+
129
+ it('should register message listener on transport', () => {
130
+ expect(transport.listeners.size).toBe(1);
131
+ });
132
+ });
133
+
134
+ describe('send', () => {
135
+ it('should send request and receive response', async () => {
136
+ const request: RpcRequest = {
137
+ id: 'test-1',
138
+ jsonrpc: '2.0',
139
+ method: 'wallet/accounts',
140
+ params: {},
141
+ };
142
+
143
+ // Start send, then simulate response
144
+ const sendPromise = client.send(request);
145
+
146
+ // Use queueMicrotask to ensure send() has started
147
+ await flushMicrotask();
148
+
149
+ transport.simulateResponse({
150
+ id: 'test-1',
151
+ jsonrpc: '2.0',
152
+ result: { accounts: ['0.0.12345'] },
153
+ });
154
+
155
+ const response = await sendPromise;
156
+
157
+ assertRpcSuccess(response);
158
+ expect(response.id).toBe('test-1');
159
+ expect(response.result).toEqual({ accounts: ['0.0.12345'] });
160
+ });
161
+
162
+ it('should generate request ID if not provided', async () => {
163
+ const request: RpcRequest = {
164
+ jsonrpc: '2.0',
165
+ method: 'wallet/get-config',
166
+ params: {},
167
+ };
168
+
169
+ const sendPromise = client.send(request);
170
+
171
+ await flushMicrotask();
172
+
173
+ // Get the generated ID from the sent message
174
+ const sentMessage = transport.sentMessages[0];
175
+ const sentRequest = sentMessage.payload as RpcRequest;
176
+
177
+ transport.simulateResponse({
178
+ id: sentRequest.id,
179
+ jsonrpc: '2.0',
180
+ result: 'pong',
181
+ });
182
+
183
+ const response = await sendPromise;
184
+
185
+ assertRpcSuccess(response);
186
+ expect(response.result).toBe('pong');
187
+ expect(sentRequest.id).toBeDefined();
188
+ });
189
+
190
+ it('should timeout if no response received', async () => {
191
+ vi.useFakeTimers();
192
+
193
+ const shortTimeoutTransport = createMockTransport();
194
+ const shortTimeoutClient = new ReferenceClient({
195
+ transport: shortTimeoutTransport,
196
+ timeoutMs: 100,
197
+ });
198
+
199
+ const request: RpcRequest = {
200
+ id: 'timeout-test',
201
+ jsonrpc: '2.0',
202
+ method: 'wallet/get-config',
203
+ params: {},
204
+ };
205
+
206
+ const sendPromise = shortTimeoutClient.send(request);
207
+
208
+ // Advance time past timeout
209
+ vi.advanceTimersByTime(150);
210
+
211
+ await expect(sendPromise).rejects.toThrow('timed out');
212
+
213
+ shortTimeoutClient.destroy();
214
+ vi.useRealTimers();
215
+ });
216
+
217
+ it('should add timestamp if not provided', async () => {
218
+ const request: RpcRequest = {
219
+ id: 'timestamp-test',
220
+ jsonrpc: '2.0',
221
+ method: 'wallet/register-ledger',
222
+ params: {},
223
+ };
224
+
225
+ const sendPromise = client.send(request);
226
+
227
+ await flushMicrotask();
228
+
229
+ transport.simulateResponse({
230
+ id: 'timestamp-test',
231
+ jsonrpc: '2.0',
232
+ result: 'ok',
233
+ });
234
+
235
+ await sendPromise;
236
+
237
+ const sentMessage = transport.sentMessages[0];
238
+ const sentRequest = sentMessage.payload as RpcRequest;
239
+ expect(sentRequest.timestamp).toBeDefined();
240
+ expect(typeof sentRequest.timestamp).toBe('number');
241
+ });
242
+ });
243
+
244
+ describe('register', () => {
245
+ it('should register handler for method', async () => {
246
+ const handler = vi.fn().mockResolvedValue({ status: 'ok' });
247
+ client.register('wallet/accounts', handler);
248
+
249
+ // Simulate incoming request using proper format with valid method
250
+ transport.simulateRequest({
251
+ id: crypto.randomUUID(),
252
+ jsonrpc: '2.0',
253
+ method: 'wallet/accounts',
254
+ params: { data: 'test' },
255
+ });
256
+
257
+ // Give time for async handling
258
+ await new Promise((resolve) => setTimeout(resolve, 50));
259
+
260
+ expect(handler).toHaveBeenCalled();
261
+ });
262
+ });
263
+
264
+ describe('destroy', () => {
265
+ it('should clear pending requests', () => {
266
+ client.destroy();
267
+ // No way to directly check pending map, but destroy should complete without error
268
+ expect(true).toBe(true);
269
+ });
270
+
271
+ it('should remove message listener', () => {
272
+ client.destroy();
273
+ expect(transport.listeners.size).toBe(0);
274
+ });
275
+ });
276
+
277
+ describe('events', () => {
278
+ it('should emit request event for incoming requests', async () => {
279
+ const requestHandler = vi.fn();
280
+ client.on('request', requestHandler);
281
+
282
+ // Simulate incoming request using proper format with valid method
283
+ transport.simulateRequest({
284
+ id: crypto.randomUUID(),
285
+ jsonrpc: '2.0',
286
+ method: 'wallet/accounts',
287
+ params: {},
288
+ });
289
+
290
+ await new Promise((resolve) => setTimeout(resolve, 50));
291
+
292
+ expect(requestHandler).toHaveBeenCalled();
293
+ });
294
+
295
+ it('should emit response event for successful responses', async () => {
296
+ const responseHandler = vi.fn();
297
+ client.on('response', responseHandler);
298
+
299
+ const request: RpcRequest = {
300
+ id: 'response-event',
301
+ jsonrpc: '2.0',
302
+ method: 'wallet/get-config',
303
+ params: {},
304
+ };
305
+
306
+ const sendPromise = client.send(request);
307
+
308
+ await flushMicrotask();
309
+
310
+ transport.simulateResponse({
311
+ id: 'response-event',
312
+ jsonrpc: '2.0',
313
+ result: 'success',
314
+ });
315
+
316
+ await sendPromise;
317
+
318
+ expect(responseHandler).toHaveBeenCalled();
319
+ });
320
+
321
+ it('should emit response:error event for error responses', async () => {
322
+ const errorHandler = vi.fn();
323
+ client.on('response:error', errorHandler);
324
+
325
+ const request: RpcRequest = {
326
+ id: 'error-event',
327
+ jsonrpc: '2.0',
328
+ method: 'wallet/get-config',
329
+ params: {},
330
+ };
331
+
332
+ const sendPromise = client.send(request);
333
+
334
+ await flushMicrotask();
335
+
336
+ transport.simulateResponse({
337
+ id: 'error-event',
338
+ jsonrpc: '2.0',
339
+ error: {
340
+ code: -32000,
341
+ message: 'Test error',
342
+ },
343
+ });
344
+
345
+ await sendPromise;
346
+
347
+ expect(errorHandler).toHaveBeenCalled();
348
+ });
349
+
350
+ it('should emit error event on envelope handling failure', async () => {
351
+ const errorHandler = vi.fn();
352
+ client.on('error', errorHandler);
353
+
354
+ // Simulate malformed envelope (missing version)
355
+ const badEnvelope = {
356
+ type: 'invalid',
357
+ payload: null,
358
+ timestamp: Date.now(),
359
+ } as unknown as RpcEnvelope;
360
+
361
+ transport.listeners.forEach((listener) => listener(badEnvelope));
362
+
363
+ await new Promise((resolve) => setTimeout(resolve, 50));
364
+
365
+ expect(errorHandler).toHaveBeenCalled();
366
+ });
367
+ });
368
+
369
+ describe('onResponse callback', () => {
370
+ it('should call onResponse callback for responses', async () => {
371
+ const onResponse = vi.fn();
372
+ const newTransport = createMockTransport();
373
+ const clientWithCallback = new ReferenceClient({
374
+ transport: newTransport,
375
+ timeoutMs: 1000,
376
+ onResponse,
377
+ });
378
+
379
+ const request: RpcRequest = {
380
+ id: 'callback-test',
381
+ jsonrpc: '2.0',
382
+ method: 'wallet/get-config',
383
+ params: {},
384
+ };
385
+
386
+ const sendPromise = clientWithCallback.send(request);
387
+
388
+ await flushMicrotask();
389
+
390
+ newTransport.simulateResponse({
391
+ id: 'callback-test',
392
+ jsonrpc: '2.0',
393
+ result: 'callback result',
394
+ });
395
+
396
+ await sendPromise;
397
+
398
+ expect(onResponse).toHaveBeenCalledWith(
399
+ expect.objectContaining({
400
+ id: 'callback-test',
401
+ result: 'callback result',
402
+ }),
403
+ );
404
+
405
+ clientWithCallback.destroy();
406
+ });
407
+ });
408
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * HSuite Native Connect
3
+ * Copyright 2024-2025 HSuite (https://hsuite.finance)
4
+ *
5
+ * SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
6
+ *
7
+ * This file is part of HSuite Native Connect. For commercial licensing,
8
+ * visit https://hsuite.finance/licensing
9
+ */
10
+
11
+ /**
12
+ * @file Reference client implementation for dApp developers.
13
+ * Provides a complete RPC client with bidirectional message routing,
14
+ * timeout protection, and event-driven lifecycle hooks.
15
+ *
16
+ * @module @hsuite/native-connect-client/reference-client
17
+ */
18
+
19
+ import EventEmitter from 'eventemitter3';
20
+ import { RpcClient, RpcCodec, RpcServer, RpcRouter, getLogger } from '@hsuite/native-connect-sdk';
21
+ import type { RpcRequest, RpcResponse, RpcEnvelope } from '@hsuite/native-connect-types';
22
+ import { RpcRequestSchema, RpcErrorSchema, RpcSuccessSchema } from '@hsuite/native-connect-types';
23
+
24
+ /**
25
+ * @description Transport abstraction for RPC message exchange
26
+ * @property {function} send - Send RPC envelope to peer
27
+ * @property {function} onMessage - Register listener for incoming messages
28
+ * @property {function} [offMessage] - Unregister message listener
29
+ * @property {function} [onServerMessage] - Register listener for server-originated messages
30
+ * @property {function} [offServerMessage] - Unregister server message listener
31
+ */
32
+ export interface ReferenceClientTransport {
33
+ send(message: RpcEnvelope, from?: 'client' | 'server'): Promise<void>;
34
+ onMessage(listener: (message: RpcEnvelope) => void): void;
35
+ offMessage?(listener: (message: RpcEnvelope) => void): void;
36
+ onServerMessage?(listener: (message: RpcEnvelope) => void): void;
37
+ offServerMessage?(listener: (message: RpcEnvelope) => void): void;
38
+ }
39
+
40
+ /**
41
+ * @description Configuration for reference client instantiation
42
+ * @property {ReferenceClientTransport} transport - Transport layer for message exchange
43
+ * @property {number} [timeoutMs] - Request timeout in milliseconds
44
+ * @property {RpcServer} [server] - Optional RPC server for bidirectional communication
45
+ * @property {function} [onResponse] - Callback invoked on response receipt
46
+ */
47
+ export interface ReferenceClientOptions {
48
+ transport: ReferenceClientTransport;
49
+ timeoutMs?: number;
50
+ server?: RpcServer;
51
+ onResponse?(response: RpcResponse): void;
52
+ }
53
+
54
+ /**
55
+ * @description Events emitted by reference client
56
+ */
57
+ export type ReferenceClientEvent = 'request' | 'response' | 'response:error' | 'error';
58
+
59
+ /**
60
+ * @class ReferenceClient
61
+ * @augments EventEmitter
62
+ * @description Developer-facing RPC client for HSuite SDK integration testing.
63
+ *
64
+ * This is a reference implementation designed for dApp developers to understand
65
+ * and test wallet communication patterns. It demonstrates the complete RPC
66
+ * request/response lifecycle with proper timeout handling and error recovery.
67
+ *
68
+ * Features:
69
+ * - Request/response RPC communication with timeout protection
70
+ * - Bidirectional message routing for client-server scenarios
71
+ * - Event emission for request/response lifecycle hooks
72
+ * - Handler registration for incoming RPC methods
73
+ */
74
+ export class ReferenceClient extends EventEmitter<ReferenceClientEvent | string> {
75
+ private readonly rpc: RpcClient;
76
+ private readonly codec = new RpcCodec();
77
+ private readonly pending = new Map<string | number, (response: RpcResponse) => void>();
78
+ private readonly timeoutMs: number;
79
+ private readonly messageListener: (envelope: RpcEnvelope) => void;
80
+ private readonly server?: RpcServer;
81
+ private readonly router = new RpcRouter();
82
+ private readonly logger = getLogger().scoped?.('ReferenceClient') ?? getLogger();
83
+
84
+ constructor(private readonly options: ReferenceClientOptions) {
85
+ super();
86
+ this.timeoutMs = options.timeoutMs ?? 5000;
87
+ this.rpc = new RpcClient({
88
+ transport: options.transport,
89
+ codec: this.codec,
90
+ timeoutMs: this.timeoutMs,
91
+ });
92
+ this.server = options.server;
93
+ this.messageListener = (envelope) => this.handleEnvelope(envelope);
94
+ options.transport.onMessage(this.messageListener);
95
+ options.transport.onServerMessage?.((envelope) => this.handleEnvelope(envelope));
96
+ }
97
+
98
+ /**
99
+ * @description Send RPC request and await response with timeout
100
+ * @param request - RPC request payload
101
+ * @returns Promise resolving to RPC response
102
+ * @throws Error if request times out
103
+ */
104
+ async send<T = unknown>(request: RpcRequest): Promise<RpcResponse<T>> {
105
+ const id = request.id ?? crypto.randomUUID();
106
+ const finalRequest: RpcRequest = { ...request, id, timestamp: request.timestamp ?? Date.now() };
107
+ this.logger.debug('Sending RPC request', { method: finalRequest.method, id: String(id) });
108
+ const promise = new Promise<RpcResponse<T>>((resolve, reject) => {
109
+ const timeout = setTimeout(() => {
110
+ this.pending.delete(id);
111
+ reject(new Error(`RPC request ${String(id)} timed out after ${this.timeoutMs}ms`));
112
+ }, this.timeoutMs);
113
+
114
+ this.pending.set(id, (response) => {
115
+ clearTimeout(timeout);
116
+ this.logger.debug('RPC response received', { id: String(id), method: finalRequest.method });
117
+ resolve(response as RpcResponse<T>);
118
+ });
119
+ });
120
+
121
+ await this.rpc.send(finalRequest);
122
+ return promise;
123
+ }
124
+
125
+ /**
126
+ * @description Register handler for incoming RPC method
127
+ * @param method - RPC method name
128
+ * @param handler - Handler function returning response data
129
+ */
130
+ register(method: string, handler: Parameters<typeof RpcRouter.prototype.register>[1]): void {
131
+ this.router.register(method, handler);
132
+ }
133
+
134
+ /**
135
+ * @description Dispose client and cleanup listeners
136
+ */
137
+ destroy(): void {
138
+ this.pending.clear();
139
+ this.options.transport.offMessage?.(this.messageListener);
140
+ }
141
+
142
+ private async handleEnvelope(envelope: RpcEnvelope): Promise<void> {
143
+ try {
144
+ const decoded = await this.codec.decode(envelope);
145
+ const payload = decoded.payload;
146
+
147
+ if (this.isRequest(payload)) {
148
+ const handled = this.server ? await this.server.handle(decoded) : undefined;
149
+
150
+ if (!handled) {
151
+ const response = await this.router.handle(payload);
152
+ const responseEnvelope = await this.codec.encodeResponse(response);
153
+ await this.options.transport.send(responseEnvelope, 'server');
154
+
155
+ this.logger.debug('RPC response sent from local router', {
156
+ method: payload.method,
157
+ id: String(payload.id),
158
+ });
159
+ }
160
+
161
+ this.emit('request', payload);
162
+ this.logger.info('RPC request handled locally', {
163
+ method: payload.method,
164
+ id: String(payload.id),
165
+ });
166
+ return;
167
+ }
168
+
169
+ const result = payload as RpcResponse;
170
+ const id =
171
+ typeof result.id === 'string' || typeof result.id === 'number' ? result.id : 'unknown';
172
+ const callback = this.pending.get(id);
173
+
174
+ if (callback) {
175
+ callback(result);
176
+ this.pending.delete(id);
177
+ }
178
+
179
+ this.options.onResponse?.(result);
180
+ this.emit(this.isError(result) ? 'response:error' : 'response', result);
181
+ this.logger.info('RPC response processed', {
182
+ id: String(result.id),
183
+ status: this.isError(result) ? 'error' : 'success',
184
+ });
185
+ } catch (error) {
186
+ this.emit('error', error);
187
+ this.logger.error('RPC envelope handling failed', { error });
188
+ }
189
+ }
190
+
191
+ private isRequest(payload: unknown): payload is RpcRequest {
192
+ return RpcRequestSchema.safeParse(payload).success;
193
+ }
194
+
195
+ private isError(payload: RpcResponse): payload is ReturnType<typeof RpcErrorSchema.parse> {
196
+ return RpcErrorSchema.safeParse(payload).success;
197
+ }
198
+
199
+ private isSuccess<T>(payload: RpcResponse<T>): payload is RpcResponse<T> & { result: T } {
200
+ return RpcSuccessSchema.safeParse(payload).success;
201
+ }
202
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { EndToEndHarness } from '../src/protocol/e2e-harness';
3
+ import { createChannelInvite, encodeChannelInvite } from '@hsuite/native-connect-sdk';
4
+
5
+ const mockAdapter = {
6
+ manifest: {
7
+ metadata: {
8
+ id: 'mock-ledger',
9
+ displayName: 'Mock Ledger',
10
+ description: 'Test ledger adapter',
11
+ defaultNetworkId: 'mock:testnet',
12
+ capabilities: {
13
+ supportsMultiSign: false,
14
+ supportsScheduledTx: false,
15
+ supportsSmartContracts: false,
16
+ },
17
+ },
18
+ networks: [{ networkId: 'mock:testnet' }],
19
+ },
20
+ initialize: vi.fn(async () => undefined),
21
+ deriveAccounts: vi.fn(async () => []),
22
+ sign: vi.fn(async () => ({
23
+ signature: 'sign',
24
+ publicKey: 'pk',
25
+ payloadHash: 'hash',
26
+ algorithm: 'mock',
27
+ })),
28
+ } as unknown as Parameters<EndToEndHarness['constructor']>[0]['ledgerAdapter'];
29
+
30
+ /**
31
+ * Create a mock channel invite for testing
32
+ */
33
+ function createMockInvite() {
34
+ return createChannelInvite({
35
+ type: 'session',
36
+ app: {
37
+ id: 'test-app',
38
+ name: 'Test App',
39
+ },
40
+ context: {
41
+ ledgerId: 'mock-ledger',
42
+ networkId: 'mock:testnet',
43
+ },
44
+ permissions: [],
45
+ });
46
+ }
47
+
48
+ describe('EndToEndHarness', () => {
49
+ it('establishes a session and allows RPC', async () => {
50
+ const invite = createMockInvite();
51
+ const encodedInvite = encodeChannelInvite(invite);
52
+
53
+ const harness = new EndToEndHarness({
54
+ ledgerAdapter: mockAdapter,
55
+ session: {
56
+ request: encodedInvite,
57
+ },
58
+ });
59
+
60
+ const sessionId = await harness.establishSession();
61
+ expect(sessionId).toBeTypeOf('string');
62
+ await harness.teardown();
63
+ });
64
+ });