@bigmistqke/rpc 0.1.3 → 0.1.5

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.
@@ -0,0 +1,514 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { $WEBSOCKET, expose, handle, rpc } from '../src/websocket'
3
+ import {
4
+ $MESSENGER_ERROR,
5
+ $MESSENGER_HANDLE,
6
+ $MESSENGER_REQUEST,
7
+ $MESSENGER_RESPONSE,
8
+ $MESSENGER_RPC_REQUEST,
9
+ } from '../src/protocol'
10
+
11
+ // Helper to flush pending promises
12
+ const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0))
13
+
14
+ // Mock WebSocket - simulates JSON serialization round-trip
15
+ function createMockWebSocket() {
16
+ const handlers: Array<(event: unknown) => void> = []
17
+ return {
18
+ send: vi.fn(),
19
+ close: vi.fn(),
20
+ addEventListener: vi.fn((type: string, handler: (event: unknown) => void) => {
21
+ if (type === 'message') {
22
+ handlers.push(handler)
23
+ }
24
+ }),
25
+ _emit(data: any) {
26
+ // Simulate JSON round-trip like a real WebSocket
27
+ const serialized = JSON.stringify(data)
28
+ handlers.forEach(h => h({ data: serialized }))
29
+ },
30
+ _handlers: handlers,
31
+ }
32
+ }
33
+
34
+ // Mock WebSocket pair - wires two sockets together with JSON serialization
35
+ function createMockWebSocketPair() {
36
+ const ws1 = createMockWebSocket()
37
+ const ws2 = createMockWebSocket()
38
+
39
+ // Wire sockets together: send on one emits on the other (with JSON round-trip)
40
+ ws1.send = vi.fn((data: string) => {
41
+ setTimeout(() => {
42
+ const parsed = JSON.parse(data)
43
+ ws2._emit(parsed)
44
+ }, 0)
45
+ })
46
+ ws2.send = vi.fn((data: string) => {
47
+ setTimeout(() => {
48
+ const parsed = JSON.parse(data)
49
+ ws1._emit(parsed)
50
+ }, 0)
51
+ })
52
+
53
+ return { ws1, ws2 }
54
+ }
55
+
56
+ describe('expose', () => {
57
+ it('should handle RPC requests', async () => {
58
+ const ws = createMockWebSocket()
59
+
60
+ expose(
61
+ {
62
+ greet: (name: string) => `Hello, ${name}!`,
63
+ },
64
+ { to: ws },
65
+ )
66
+
67
+ ws._emit({
68
+ [$MESSENGER_REQUEST]: 1,
69
+ payload: {
70
+ [$MESSENGER_RPC_REQUEST]: true,
71
+ topics: ['greet'],
72
+ args: ['World'],
73
+ },
74
+ })
75
+
76
+ await flushPromises()
77
+
78
+ expect(ws.send).toHaveBeenCalledWith(
79
+ JSON.stringify({
80
+ [$MESSENGER_RESPONSE]: 1,
81
+ payload: 'Hello, World!',
82
+ }),
83
+ )
84
+ })
85
+
86
+ it('should handle nested method calls', async () => {
87
+ const ws = createMockWebSocket()
88
+
89
+ expose(
90
+ {
91
+ user: {
92
+ profile: {
93
+ getName: () => 'John Doe',
94
+ },
95
+ },
96
+ },
97
+ { to: ws },
98
+ )
99
+
100
+ ws._emit({
101
+ [$MESSENGER_REQUEST]: 1,
102
+ payload: {
103
+ [$MESSENGER_RPC_REQUEST]: true,
104
+ topics: ['user', 'profile', 'getName'],
105
+ args: [],
106
+ },
107
+ })
108
+
109
+ await flushPromises()
110
+
111
+ expect(ws.send).toHaveBeenCalledWith(
112
+ JSON.stringify({
113
+ [$MESSENGER_RESPONSE]: 1,
114
+ payload: 'John Doe',
115
+ }),
116
+ )
117
+ })
118
+
119
+ it('should handle async methods', async () => {
120
+ const ws = createMockWebSocket()
121
+
122
+ expose(
123
+ {
124
+ asyncMethod: async () => {
125
+ await new Promise(resolve => setTimeout(resolve, 10))
126
+ return 'async result'
127
+ },
128
+ },
129
+ { to: ws },
130
+ )
131
+
132
+ ws._emit({
133
+ [$MESSENGER_REQUEST]: 1,
134
+ payload: {
135
+ [$MESSENGER_RPC_REQUEST]: true,
136
+ topics: ['asyncMethod'],
137
+ args: [],
138
+ },
139
+ })
140
+
141
+ await new Promise(resolve => setTimeout(resolve, 20))
142
+
143
+ expect(ws.send).toHaveBeenCalledWith(
144
+ JSON.stringify({
145
+ [$MESSENGER_RESPONSE]: 1,
146
+ payload: 'async result',
147
+ }),
148
+ )
149
+ })
150
+
151
+ it('should handle void-returning methods (undefined survives JSON round-trip)', async () => {
152
+ const ws = createMockWebSocket()
153
+
154
+ expose(
155
+ {
156
+ doSomething: () => {},
157
+ },
158
+ { to: ws },
159
+ )
160
+
161
+ ws._emit({
162
+ [$MESSENGER_REQUEST]: 1,
163
+ payload: {
164
+ [$MESSENGER_RPC_REQUEST]: true,
165
+ topics: ['doSomething'],
166
+ args: [],
167
+ },
168
+ })
169
+
170
+ await flushPromises()
171
+
172
+ // payload is undefined which gets stripped by JSON.stringify
173
+ // ResponseShape.validate should still pass with optional payload
174
+ const sent = JSON.parse(ws.send.mock.calls[0]![0])
175
+ expect(sent[$MESSENGER_RESPONSE]).toBe(1)
176
+ })
177
+
178
+ it('should return handle response when method returns handle()', async () => {
179
+ const ws = createMockWebSocket()
180
+
181
+ expose(
182
+ {
183
+ init: () =>
184
+ handle({
185
+ getValue: () => 42,
186
+ }),
187
+ },
188
+ { to: ws },
189
+ )
190
+
191
+ ws._emit({
192
+ [$MESSENGER_REQUEST]: 1,
193
+ payload: {
194
+ [$MESSENGER_RPC_REQUEST]: true,
195
+ topics: ['init'],
196
+ args: [],
197
+ },
198
+ })
199
+
200
+ await flushPromises()
201
+
202
+ const sent = JSON.parse(ws.send.mock.calls[0]![0])
203
+ expect(sent[$MESSENGER_RESPONSE]).toBe(1)
204
+ expect(sent.payload[$MESSENGER_HANDLE]).toContain('__rpc_handle_')
205
+ })
206
+
207
+ it('should handle namespaced RPC calls from handle()', async () => {
208
+ const ws = createMockWebSocket()
209
+ const getValue = vi.fn(() => 42)
210
+
211
+ expose(
212
+ {
213
+ init: () =>
214
+ handle({
215
+ getValue,
216
+ }),
217
+ },
218
+ { to: ws },
219
+ )
220
+
221
+ // First call init to get namespace ID
222
+ ws._emit({
223
+ [$MESSENGER_REQUEST]: 1,
224
+ payload: {
225
+ [$MESSENGER_RPC_REQUEST]: true,
226
+ topics: ['init'],
227
+ args: [],
228
+ },
229
+ })
230
+
231
+ await flushPromises()
232
+
233
+ // Extract namespace ID from response
234
+ const initResponse = JSON.parse(ws.send.mock.calls[0]![0])
235
+ const namespaceId = initResponse.payload[$MESSENGER_HANDLE]
236
+
237
+ // Now call method on the handle
238
+ ws._emit({
239
+ [$MESSENGER_REQUEST]: 2,
240
+ payload: {
241
+ [$MESSENGER_RPC_REQUEST]: true,
242
+ topics: [namespaceId, 'getValue'],
243
+ args: [],
244
+ },
245
+ })
246
+
247
+ await flushPromises()
248
+
249
+ expect(getValue).toHaveBeenCalled()
250
+ const response = JSON.parse(ws.send.mock.calls[1]![0])
251
+ expect(response).toEqual({
252
+ [$MESSENGER_RESPONSE]: 2,
253
+ payload: 42,
254
+ })
255
+ })
256
+
257
+ it('should handle errors in RPC methods', async () => {
258
+ const ws = createMockWebSocket()
259
+
260
+ expose(
261
+ {
262
+ failingMethod: () => {
263
+ throw new Error('test error')
264
+ },
265
+ },
266
+ { to: ws },
267
+ )
268
+
269
+ ws._emit({
270
+ [$MESSENGER_REQUEST]: 1,
271
+ payload: {
272
+ [$MESSENGER_RPC_REQUEST]: true,
273
+ topics: ['failingMethod'],
274
+ args: [],
275
+ },
276
+ })
277
+
278
+ await flushPromises()
279
+
280
+ const sent = JSON.parse(ws.send.mock.calls[0]![0])
281
+ expect(sent[$MESSENGER_ERROR]).toBe(1)
282
+ })
283
+
284
+ it('should ignore non-request messages', async () => {
285
+ const ws = createMockWebSocket()
286
+
287
+ expose(
288
+ {
289
+ test: () => 'ok',
290
+ },
291
+ { to: ws },
292
+ )
293
+
294
+ ws._emit({ random: 'data' })
295
+ ws._emit({ [$MESSENGER_RESPONSE]: 1, payload: 'response' })
296
+
297
+ await flushPromises()
298
+
299
+ expect(ws.send).not.toHaveBeenCalled()
300
+ })
301
+
302
+ it('should ignore invalid JSON', async () => {
303
+ const ws = createMockWebSocket()
304
+
305
+ expose(
306
+ {
307
+ test: () => 'ok',
308
+ },
309
+ { to: ws },
310
+ )
311
+
312
+ // Send raw invalid JSON string directly to handlers
313
+ ws._handlers.forEach(h => h({ data: 'not valid json{' }))
314
+
315
+ await flushPromises()
316
+
317
+ expect(ws.send).not.toHaveBeenCalled()
318
+ })
319
+ })
320
+
321
+ describe('rpc', () => {
322
+ it('should call remote methods and receive results', async () => {
323
+ const { ws1, ws2 } = createMockWebSocketPair()
324
+
325
+ expose({ add: (a: number, b: number) => a + b }, { to: ws2 })
326
+
327
+ const proxy = rpc<{ add: (a: number, b: number) => number }>(ws1)
328
+
329
+ const result = await proxy.add(2, 3)
330
+ expect(result).toBe(5)
331
+ })
332
+
333
+ it('should handle nested method calls via proxy', async () => {
334
+ const { ws1, ws2 } = createMockWebSocketPair()
335
+
336
+ expose(
337
+ {
338
+ math: {
339
+ multiply: (a: number, b: number) => a * b,
340
+ },
341
+ },
342
+ { to: ws2 },
343
+ )
344
+
345
+ const proxy = rpc<{ math: { multiply: (a: number, b: number) => number } }>(ws1)
346
+
347
+ const result = await proxy.math.multiply(4, 5)
348
+ expect(result).toBe(20)
349
+ })
350
+
351
+ it('should handle errors from remote methods', async () => {
352
+ const { ws1, ws2 } = createMockWebSocketPair()
353
+
354
+ expose(
355
+ {
356
+ failingMethod: () => {
357
+ throw new Error('Remote error')
358
+ },
359
+ },
360
+ { to: ws2 },
361
+ )
362
+
363
+ const proxy = rpc<{ failingMethod: () => void }>(ws1)
364
+
365
+ await expect(proxy.failingMethod()).rejects.toThrow()
366
+ })
367
+
368
+ it('should handle multiple concurrent requests', async () => {
369
+ const { ws1, ws2 } = createMockWebSocketPair()
370
+
371
+ expose(
372
+ {
373
+ delayed: async (ms: number, value: string) => {
374
+ await new Promise(resolve => setTimeout(resolve, ms))
375
+ return value
376
+ },
377
+ },
378
+ { to: ws2 },
379
+ )
380
+
381
+ const proxy = rpc<{ delayed: (ms: number, value: string) => Promise<string> }>(ws1)
382
+
383
+ const [result1, result2, result3] = await Promise.all([
384
+ proxy.delayed(30, 'first'),
385
+ proxy.delayed(10, 'second'),
386
+ proxy.delayed(20, 'third'),
387
+ ])
388
+
389
+ expect(result1).toBe('first')
390
+ expect(result2).toBe('second')
391
+ expect(result3).toBe('third')
392
+ })
393
+
394
+ it('should handle void-returning methods', async () => {
395
+ const { ws1, ws2 } = createMockWebSocketPair()
396
+
397
+ const sideEffect = vi.fn()
398
+ expose(
399
+ {
400
+ fire: () => {
401
+ sideEffect()
402
+ },
403
+ },
404
+ { to: ws2 },
405
+ )
406
+
407
+ const proxy = rpc<{ fire: () => void }>(ws1)
408
+
409
+ await proxy.fire()
410
+ expect(sideEffect).toHaveBeenCalled()
411
+ })
412
+
413
+ it('should create sub-proxy when method returns handle()', async () => {
414
+ const { ws1, ws2 } = createMockWebSocketPair()
415
+
416
+ expose(
417
+ {
418
+ init: (multiplier: number) =>
419
+ handle({
420
+ multiply: (a: number) => a * multiplier,
421
+ }),
422
+ },
423
+ { to: ws2 },
424
+ )
425
+
426
+ const proxy = rpc<{
427
+ init: (multiplier: number) => { multiply: (a: number) => number }
428
+ }>(ws1)
429
+
430
+ const calculator = await proxy.init(10)
431
+ const result = await calculator.multiply(5)
432
+ expect(result).toBe(50)
433
+ })
434
+
435
+ it('should handle async methods that return handle()', async () => {
436
+ const { ws1, ws2 } = createMockWebSocketPair()
437
+
438
+ expose(
439
+ {
440
+ asyncInit: async (config: { prefix: string }) => {
441
+ await new Promise(resolve => setTimeout(resolve, 10))
442
+ return handle({
443
+ greet: (name: string) => `${config.prefix} ${name}!`,
444
+ })
445
+ },
446
+ },
447
+ { to: ws2 },
448
+ )
449
+
450
+ const proxy = rpc<{
451
+ asyncInit: (config: { prefix: string }) => Promise<{ greet: (name: string) => string }>
452
+ }>(ws1)
453
+
454
+ const greeter = await proxy.asyncInit({ prefix: 'Hello,' })
455
+ const result = await greeter.greet('World')
456
+ expect(result).toBe('Hello, World!')
457
+ })
458
+
459
+ it('should handle nested handle() calls', async () => {
460
+ const { ws1, ws2 } = createMockWebSocketPair()
461
+
462
+ expose(
463
+ {
464
+ createOuter: () =>
465
+ handle({
466
+ createInner: () =>
467
+ handle({
468
+ getValue: () => 'nested value',
469
+ }),
470
+ }),
471
+ },
472
+ { to: ws2 },
473
+ )
474
+
475
+ const proxy = rpc<{
476
+ createOuter: () => {
477
+ createInner: () => {
478
+ getValue: () => string
479
+ }
480
+ }
481
+ }>(ws1)
482
+
483
+ const outer = await proxy.createOuter()
484
+ const inner = await outer.createInner()
485
+ const result = await inner.getValue()
486
+ expect(result).toBe('nested value')
487
+ })
488
+
489
+ it('should expose $WEBSOCKET with the original websocket', () => {
490
+ const ws = createMockWebSocket()
491
+ const proxy = rpc<{ test: () => void }>(ws)
492
+
493
+ // Access the underlying websocket via $WEBSOCKET
494
+ const isSame = proxy[$WEBSOCKET] === ws
495
+ expect(isSame).toBe(true)
496
+ })
497
+
498
+ it('should support bidirectional RPC on the same websocket', async () => {
499
+ const { ws1, ws2 } = createMockWebSocketPair()
500
+
501
+ // Side A exposes methods and calls side B
502
+ expose({ ping: () => 'pong' }, { to: ws1 })
503
+ const proxyB = rpc<{ echo: (msg: string) => string }>(ws1)
504
+
505
+ // Side B exposes methods and calls side A
506
+ expose({ echo: (msg: string) => msg }, { to: ws2 })
507
+ const proxyA = rpc<{ ping: () => string }>(ws2)
508
+
509
+ const [pong, echoed] = await Promise.all([proxyA.ping(), proxyB.echo('hello')])
510
+
511
+ expect(pong).toBe('pong')
512
+ expect(echoed).toBe('hello')
513
+ })
514
+ })
package/tsup.config.ts CHANGED
@@ -2,7 +2,8 @@ import { defineConfig } from 'tsup'
2
2
 
3
3
  export default defineConfig({
4
4
  entry: {
5
- messenger: 'src/messenger.ts',
5
+ messenger: 'src/messenger/index.ts',
6
+ websocket: 'src/websocket/index.ts',
6
7
  stream: 'src/stream/index.ts',
7
8
  fetch: 'src/fetch/index.ts',
8
9
  'fetch-node': 'src/fetch/node.ts',