@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.
- package/README.md +51 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/coverage-summary.json +10 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +161 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +161 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov-report/src/handlers/index.html +146 -0
- package/coverage/lcov-report/src/handlers/session-handler.ts.html +223 -0
- package/coverage/lcov-report/src/handlers/signing-handler.ts.html +217 -0
- package/coverage/lcov-report/src/handlers/wallet-handler.ts.html +193 -0
- package/coverage/lcov-report/src/harness/index.html +116 -0
- package/coverage/lcov-report/src/harness/signing-harness.ts.html +352 -0
- package/coverage/lcov-report/src/index.html +146 -0
- package/coverage/lcov-report/src/memory-transport.ts.html +358 -0
- package/coverage/lcov-report/src/protocol/e2e-harness.ts.html +568 -0
- package/coverage/lcov-report/src/protocol/index.html +116 -0
- package/coverage/lcov-report/src/query-client.ts.html +277 -0
- package/coverage/lcov-report/src/reference-client.ts.html +691 -0
- package/coverage/lcov.info +981 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/handlers/index.html +146 -0
- package/coverage/src/handlers/session-handler.ts.html +223 -0
- package/coverage/src/handlers/signing-handler.ts.html +217 -0
- package/coverage/src/handlers/wallet-handler.ts.html +193 -0
- package/coverage/src/harness/index.html +116 -0
- package/coverage/src/harness/signing-harness.ts.html +352 -0
- package/coverage/src/index.html +146 -0
- package/coverage/src/memory-transport.ts.html +358 -0
- package/coverage/src/protocol/e2e-harness.ts.html +568 -0
- package/coverage/src/protocol/index.html +116 -0
- package/coverage/src/query-client.ts.html +277 -0
- package/coverage/src/reference-client.ts.html +691 -0
- package/dist/handlers/session-handler.d.ts +22 -0
- package/dist/handlers/session-handler.d.ts.map +1 -0
- package/dist/handlers/session-handler.js +41 -0
- package/dist/handlers/session-handler.js.map +1 -0
- package/dist/handlers/signing-handler.d.ts +22 -0
- package/dist/handlers/signing-handler.d.ts.map +1 -0
- package/dist/handlers/signing-handler.js +39 -0
- package/dist/handlers/signing-handler.js.map +1 -0
- package/dist/handlers/wallet-handler.d.ts +22 -0
- package/dist/handlers/wallet-handler.d.ts.map +1 -0
- package/dist/handlers/wallet-handler.js +32 -0
- package/dist/handlers/wallet-handler.js.map +1 -0
- package/dist/harness/signing-harness.d.ts +47 -0
- package/dist/harness/signing-harness.d.ts.map +1 -0
- package/dist/harness/signing-harness.js +79 -0
- package/dist/harness/signing-harness.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/memory-transport-options.d.ts +27 -0
- package/dist/memory-transport-options.d.ts.map +1 -0
- package/dist/memory-transport-options.js +11 -0
- package/dist/memory-transport-options.js.map +1 -0
- package/dist/memory-transport.d.ts +55 -0
- package/dist/memory-transport.d.ts.map +1 -0
- package/dist/memory-transport.js +81 -0
- package/dist/memory-transport.js.map +1 -0
- package/dist/protocol/e2e-harness.d.ts +65 -0
- package/dist/protocol/e2e-harness.d.ts.map +1 -0
- package/dist/protocol/e2e-harness.js +130 -0
- package/dist/protocol/e2e-harness.js.map +1 -0
- package/dist/protocol/index.d.ts +22 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +22 -0
- package/dist/protocol/index.js.map +1 -0
- package/dist/query-client.d.ts +41 -0
- package/dist/query-client.d.ts.map +1 -0
- package/dist/query-client.js +52 -0
- package/dist/query-client.js.map +1 -0
- package/dist/reference-client.d.ts +100 -0
- package/dist/reference-client.d.ts.map +1 -0
- package/dist/reference-client.js +149 -0
- package/dist/reference-client.js.map +1 -0
- package/package.json +46 -0
- package/src/handlers/session-handler.ts +46 -0
- package/src/handlers/signing-handler.ts +44 -0
- package/src/handlers/wallet-handler.ts +36 -0
- package/src/harness/signing-harness.ts +89 -0
- package/src/index.ts +24 -0
- package/src/memory-transport-options.ts +28 -0
- package/src/memory-transport.ts +91 -0
- package/src/protocol/e2e-harness.ts +161 -0
- package/src/protocol/index.ts +23 -0
- package/src/query-client.ts +64 -0
- package/src/reference-client.spec.ts +408 -0
- package/src/reference-client.ts +202 -0
- package/tests/e2e-harness.spec.ts +64 -0
- package/tests/realnet-session.spec.ts +303 -0
- package/tests/signing-harness.spec.ts +41 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +17 -0
- 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
|
+
});
|