@roj-ai/transport 0.0.2
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/dist/client/client-connection.d.ts +48 -0
- package/dist/client/client-connection.d.ts.map +1 -0
- package/dist/client/client-connection.js +160 -0
- package/dist/client/index.d.ts +10 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +7 -0
- package/dist/core/connection.d.ts +59 -0
- package/dist/core/connection.d.ts.map +1 -0
- package/dist/core/connection.js +165 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +13 -0
- package/dist/core/protocol.d.ts +206 -0
- package/dist/core/protocol.d.ts.map +1 -0
- package/dist/core/protocol.js +93 -0
- package/dist/core/result.d.ts +26 -0
- package/dist/core/result.d.ts.map +1 -0
- package/dist/core/result.js +20 -0
- package/dist/core/router.d.ts +79 -0
- package/dist/core/router.d.ts.map +1 -0
- package/dist/core/router.js +119 -0
- package/dist/core/types.d.ts +44 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +14 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/platform/browser.d.ts +14 -0
- package/dist/platform/browser.d.ts.map +1 -0
- package/dist/platform/browser.js +65 -0
- package/dist/platform/bun.d.ts +69 -0
- package/dist/platform/bun.d.ts.map +1 -0
- package/dist/platform/bun.js +152 -0
- package/dist/platform/types.d.ts +83 -0
- package/dist/platform/types.d.ts.map +1 -0
- package/dist/platform/types.js +14 -0
- package/dist/server/connection-manager.d.ts +106 -0
- package/dist/server/connection-manager.d.ts.map +1 -0
- package/dist/server/connection-manager.js +234 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +8 -0
- package/dist/server/server-connection.d.ts +50 -0
- package/dist/server/server-connection.d.ts.map +1 -0
- package/dist/server/server-connection.js +84 -0
- package/package.json +52 -0
- package/src/__tests__/integration.test.ts +443 -0
- package/src/client/client-connection.ts +207 -0
- package/src/client/index.ts +24 -0
- package/src/core/connection.ts +203 -0
- package/src/core/index.ts +52 -0
- package/src/core/protocol.test.ts +235 -0
- package/src/core/protocol.ts +348 -0
- package/src/core/result.ts +35 -0
- package/src/core/router.test.ts +248 -0
- package/src/core/router.ts +186 -0
- package/src/core/types.ts +85 -0
- package/src/index.ts +80 -0
- package/src/platform/browser.ts +79 -0
- package/src/platform/bun.ts +220 -0
- package/src/platform/types.ts +102 -0
- package/src/server/connection-manager.ts +298 -0
- package/src/server/index.ts +26 -0
- package/src/server/server-connection.ts +122 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration Tests for Transport Package
|
|
3
|
+
*
|
|
4
|
+
* These tests run actual WebSocket server and client to verify
|
|
5
|
+
* real-world communication scenarios with notification-only protocol.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Server } from 'bun'
|
|
9
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'bun:test'
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
|
|
12
|
+
import { ClientConnection } from '../client/client-connection.js'
|
|
13
|
+
import { defineProtocol, notification } from '../core/protocol.js'
|
|
14
|
+
import { BunServerWebSocketAdapter, bunWebSocketFactory, createBunWebSocketHandlers } from '../platform/bun.js'
|
|
15
|
+
import type { IServerWebSocket } from '../platform/types.js'
|
|
16
|
+
import { ServerConnection } from '../server/server-connection.js'
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Test Protocols (notification-only)
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
// Client -> Server (notifications)
|
|
23
|
+
const clientProtocol = defineProtocol({
|
|
24
|
+
userMessage: notification({
|
|
25
|
+
input: z.object({ sessionId: z.string(), content: z.string() }),
|
|
26
|
+
}),
|
|
27
|
+
userStatus: notification({
|
|
28
|
+
input: z.object({ status: z.enum(['online', 'away', 'offline']) }),
|
|
29
|
+
}),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Server -> Client (notifications)
|
|
33
|
+
const serverProtocol = defineProtocol({
|
|
34
|
+
serverEvent: notification({
|
|
35
|
+
input: z.object({ eventType: z.string(), data: z.unknown() }),
|
|
36
|
+
}),
|
|
37
|
+
agentMessage: notification({
|
|
38
|
+
input: z.object({ sessionId: z.string(), content: z.string() }),
|
|
39
|
+
}),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Test Server Setup
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
interface TestServerState {
|
|
47
|
+
server: Server
|
|
48
|
+
port: number
|
|
49
|
+
connections: Map<string, ServerConnection<typeof clientProtocol._def, typeof serverProtocol._def>>
|
|
50
|
+
receivedNotifications: Array<{ type: string; input: unknown }>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createTestServer(): Promise<TestServerState> {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const connections = new Map<string, ServerConnection<typeof clientProtocol._def, typeof serverProtocol._def>>()
|
|
56
|
+
const receivedNotifications: Array<{ type: string; input: unknown }> = []
|
|
57
|
+
|
|
58
|
+
const wsHandlers = createBunWebSocketHandlers({
|
|
59
|
+
onOpen: (ws: IServerWebSocket) => {
|
|
60
|
+
const serverConn = new ServerConnection({
|
|
61
|
+
receiveProtocol: clientProtocol,
|
|
62
|
+
sendProtocol: serverProtocol,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Set up handlers for incoming notifications
|
|
66
|
+
serverConn.setHandlers({
|
|
67
|
+
userMessage: async (input) => {
|
|
68
|
+
receivedNotifications.push({ type: 'userMessage', input })
|
|
69
|
+
},
|
|
70
|
+
userStatus: async (input) => {
|
|
71
|
+
receivedNotifications.push({ type: 'userStatus', input })
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
serverConn.attach(ws)
|
|
76
|
+
connections.set(serverConn.getConnectionId(), serverConn)
|
|
77
|
+
;(ws as unknown as { __serverConn: ServerConnection<typeof clientProtocol._def, typeof serverProtocol._def> }).__serverConn = serverConn
|
|
78
|
+
},
|
|
79
|
+
onClose: (ws: IServerWebSocket) => {
|
|
80
|
+
const serverConn = (ws as unknown as { __serverConn: ServerConnection<typeof clientProtocol._def, typeof serverProtocol._def> }).__serverConn
|
|
81
|
+
if (serverConn) {
|
|
82
|
+
connections.delete(serverConn.getConnectionId())
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
onMessage: (ws: IServerWebSocket, message: string) => {
|
|
86
|
+
// Forward message to the WebSocket's onmessage handler
|
|
87
|
+
ws.onmessage?.(message)
|
|
88
|
+
},
|
|
89
|
+
onError: () => {},
|
|
90
|
+
getData: () => ({}),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const server = Bun.serve({
|
|
94
|
+
port: 0, // Random available port
|
|
95
|
+
fetch(req, server) {
|
|
96
|
+
if (server.upgrade(req)) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
return new Response('WebSocket server', { status: 200 })
|
|
100
|
+
},
|
|
101
|
+
websocket: {
|
|
102
|
+
open: wsHandlers.open,
|
|
103
|
+
close: wsHandlers.close,
|
|
104
|
+
message: wsHandlers.message,
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
resolve({
|
|
109
|
+
server,
|
|
110
|
+
port: server.port,
|
|
111
|
+
connections,
|
|
112
|
+
receivedNotifications,
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// Integration Tests
|
|
119
|
+
// ============================================================================
|
|
120
|
+
|
|
121
|
+
describe('Transport Integration Tests', () => {
|
|
122
|
+
let testServer: TestServerState
|
|
123
|
+
|
|
124
|
+
beforeAll(async () => {
|
|
125
|
+
testServer = await createTestServer()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
afterAll(() => {
|
|
129
|
+
testServer.server.stop()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
testServer.receivedNotifications.length = 0
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('Connection', () => {
|
|
137
|
+
it('should connect successfully', async () => {
|
|
138
|
+
const client = new ClientConnection({
|
|
139
|
+
url: `ws://localhost:${testServer.port}`,
|
|
140
|
+
wsFactory: bunWebSocketFactory,
|
|
141
|
+
receiveProtocol: serverProtocol,
|
|
142
|
+
sendProtocol: clientProtocol,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
await client.connect()
|
|
146
|
+
|
|
147
|
+
expect(client.isConnected()).toBe(true)
|
|
148
|
+
expect(client.getState()).toBe('connected')
|
|
149
|
+
|
|
150
|
+
await client.disconnect()
|
|
151
|
+
expect(client.isConnected()).toBe(false)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('should emit state change events', async () => {
|
|
155
|
+
const stateChanges: Array<{ from: string; to: string }> = []
|
|
156
|
+
|
|
157
|
+
const client = new ClientConnection({
|
|
158
|
+
url: `ws://localhost:${testServer.port}`,
|
|
159
|
+
wsFactory: bunWebSocketFactory,
|
|
160
|
+
receiveProtocol: serverProtocol,
|
|
161
|
+
sendProtocol: clientProtocol,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
client.on((event, data) => {
|
|
165
|
+
if (event === 'state_change') {
|
|
166
|
+
stateChanges.push(data as { from: string; to: string })
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
await client.connect()
|
|
171
|
+
await client.disconnect()
|
|
172
|
+
|
|
173
|
+
// Should have state changes: disconnected -> connecting -> connected -> disconnected
|
|
174
|
+
expect(stateChanges.length).toBeGreaterThan(2)
|
|
175
|
+
expect(stateChanges.some((s) => s.to === 'connected')).toBe(true)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('Client-to-Server Notifications', () => {
|
|
180
|
+
it('should send notification to server', async () => {
|
|
181
|
+
const client = new ClientConnection({
|
|
182
|
+
url: `ws://localhost:${testServer.port}`,
|
|
183
|
+
wsFactory: bunWebSocketFactory,
|
|
184
|
+
receiveProtocol: serverProtocol,
|
|
185
|
+
sendProtocol: clientProtocol,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
await client.connect()
|
|
189
|
+
|
|
190
|
+
// Send notification
|
|
191
|
+
client.notify.userMessage({ sessionId: 'sess-1', content: 'Hello World' })
|
|
192
|
+
|
|
193
|
+
// Wait for notification to arrive
|
|
194
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
195
|
+
|
|
196
|
+
// Verify server received the notification
|
|
197
|
+
expect(testServer.receivedNotifications).toContainEqual({
|
|
198
|
+
type: 'userMessage',
|
|
199
|
+
input: { sessionId: 'sess-1', content: 'Hello World' },
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
await client.disconnect()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should send multiple notifications', async () => {
|
|
206
|
+
const client = new ClientConnection({
|
|
207
|
+
url: `ws://localhost:${testServer.port}`,
|
|
208
|
+
wsFactory: bunWebSocketFactory,
|
|
209
|
+
receiveProtocol: serverProtocol,
|
|
210
|
+
sendProtocol: clientProtocol,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
await client.connect()
|
|
214
|
+
|
|
215
|
+
// Send multiple notifications
|
|
216
|
+
client.notify.userMessage({ sessionId: 'sess-1', content: 'First message' })
|
|
217
|
+
client.notify.userStatus({ status: 'online' })
|
|
218
|
+
client.notify.userMessage({ sessionId: 'sess-1', content: 'Second message' })
|
|
219
|
+
|
|
220
|
+
// Wait for notifications to arrive
|
|
221
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
222
|
+
|
|
223
|
+
expect(testServer.receivedNotifications.length).toBe(3)
|
|
224
|
+
expect(testServer.receivedNotifications[0]).toEqual({
|
|
225
|
+
type: 'userMessage',
|
|
226
|
+
input: { sessionId: 'sess-1', content: 'First message' },
|
|
227
|
+
})
|
|
228
|
+
expect(testServer.receivedNotifications[1]).toEqual({
|
|
229
|
+
type: 'userStatus',
|
|
230
|
+
input: { status: 'online' },
|
|
231
|
+
})
|
|
232
|
+
expect(testServer.receivedNotifications[2]).toEqual({
|
|
233
|
+
type: 'userMessage',
|
|
234
|
+
input: { sessionId: 'sess-1', content: 'Second message' },
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
await client.disconnect()
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe('Server-to-Client Notifications', () => {
|
|
242
|
+
it('should receive notifications from server', async () => {
|
|
243
|
+
const receivedNotifications: Array<{ eventType: string; data: unknown }> = []
|
|
244
|
+
|
|
245
|
+
const client = new ClientConnection({
|
|
246
|
+
url: `ws://localhost:${testServer.port}`,
|
|
247
|
+
wsFactory: bunWebSocketFactory,
|
|
248
|
+
receiveProtocol: serverProtocol,
|
|
249
|
+
sendProtocol: clientProtocol,
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// Set up notification handler
|
|
253
|
+
client.setHandlers({
|
|
254
|
+
serverEvent: async (input) => {
|
|
255
|
+
receivedNotifications.push(input)
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
await client.connect()
|
|
260
|
+
|
|
261
|
+
// Get the server connection and send notification
|
|
262
|
+
await new Promise((resolve) => setTimeout(resolve, 50)) // Wait for server to register connection
|
|
263
|
+
|
|
264
|
+
const serverConnections = Array.from(testServer.connections.values())
|
|
265
|
+
expect(serverConnections.length).toBeGreaterThan(0)
|
|
266
|
+
|
|
267
|
+
const serverConn = serverConnections[0]
|
|
268
|
+
serverConn.notify.serverEvent({ eventType: 'test_event', data: { foo: 'bar' } })
|
|
269
|
+
|
|
270
|
+
// Wait for notification to arrive
|
|
271
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
272
|
+
|
|
273
|
+
expect(receivedNotifications.length).toBe(1)
|
|
274
|
+
expect(receivedNotifications[0].eventType).toBe('test_event')
|
|
275
|
+
expect(receivedNotifications[0].data).toEqual({ foo: 'bar' })
|
|
276
|
+
|
|
277
|
+
await client.disconnect()
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('should receive agent messages from server', async () => {
|
|
281
|
+
const receivedMessages: Array<{ sessionId: string; content: string }> = []
|
|
282
|
+
|
|
283
|
+
const client = new ClientConnection({
|
|
284
|
+
url: `ws://localhost:${testServer.port}`,
|
|
285
|
+
wsFactory: bunWebSocketFactory,
|
|
286
|
+
receiveProtocol: serverProtocol,
|
|
287
|
+
sendProtocol: clientProtocol,
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
client.setHandlers({
|
|
291
|
+
agentMessage: async (input) => {
|
|
292
|
+
receivedMessages.push(input)
|
|
293
|
+
},
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
await client.connect()
|
|
297
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
298
|
+
|
|
299
|
+
const serverConnections = Array.from(testServer.connections.values())
|
|
300
|
+
const serverConn = serverConnections[0]
|
|
301
|
+
|
|
302
|
+
serverConn.notify.agentMessage({ sessionId: 'sess-1', content: 'Hello from agent!' })
|
|
303
|
+
|
|
304
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
305
|
+
|
|
306
|
+
expect(receivedMessages.length).toBe(1)
|
|
307
|
+
expect(receivedMessages[0]).toEqual({ sessionId: 'sess-1', content: 'Hello from agent!' })
|
|
308
|
+
|
|
309
|
+
await client.disconnect()
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
describe('Reconnection', () => {
|
|
314
|
+
it('should attempt reconnection when connection is lost', async () => {
|
|
315
|
+
const reconnectAttempts: number[] = []
|
|
316
|
+
const stateChanges: string[] = []
|
|
317
|
+
|
|
318
|
+
// Create a server that immediately closes connections
|
|
319
|
+
const tempServer = Bun.serve({
|
|
320
|
+
port: 0,
|
|
321
|
+
fetch(req, server) {
|
|
322
|
+
if (server.upgrade(req)) return
|
|
323
|
+
return new Response('ok')
|
|
324
|
+
},
|
|
325
|
+
websocket: {
|
|
326
|
+
open(ws) {
|
|
327
|
+
// Close connection after a brief delay to trigger reconnect
|
|
328
|
+
setTimeout(() => ws.close(1000, 'Server initiated close'), 50)
|
|
329
|
+
},
|
|
330
|
+
message() {},
|
|
331
|
+
close() {},
|
|
332
|
+
},
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
const client = new ClientConnection({
|
|
336
|
+
url: `ws://localhost:${tempServer.port}`,
|
|
337
|
+
wsFactory: bunWebSocketFactory,
|
|
338
|
+
receiveProtocol: serverProtocol,
|
|
339
|
+
sendProtocol: clientProtocol,
|
|
340
|
+
reconnect: {
|
|
341
|
+
baseDelayMs: 20,
|
|
342
|
+
maxDelayMs: 100,
|
|
343
|
+
maxAttempts: 2,
|
|
344
|
+
jitterFactor: 0,
|
|
345
|
+
},
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
client.on((event, data) => {
|
|
349
|
+
if (event === 'reconnecting') {
|
|
350
|
+
reconnectAttempts.push((data as { attempt: number }).attempt)
|
|
351
|
+
}
|
|
352
|
+
if (event === 'state_change') {
|
|
353
|
+
stateChanges.push((data as { to: string }).to)
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
await client.connect()
|
|
358
|
+
|
|
359
|
+
// Wait for server to close connection and client to attempt reconnect
|
|
360
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
361
|
+
|
|
362
|
+
// Should have attempted reconnection
|
|
363
|
+
expect(reconnectAttempts.length).toBeGreaterThan(0)
|
|
364
|
+
expect(stateChanges).toContain('reconnecting')
|
|
365
|
+
|
|
366
|
+
await client.disconnect()
|
|
367
|
+
tempServer.stop()
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
describe('Multiple Clients', () => {
|
|
372
|
+
it('should handle multiple simultaneous clients', async () => {
|
|
373
|
+
const numClients = 5
|
|
374
|
+
const clients: ClientConnection<typeof serverProtocol._def, typeof clientProtocol._def>[] = []
|
|
375
|
+
|
|
376
|
+
// Connect multiple clients
|
|
377
|
+
for (let i = 0; i < numClients; i++) {
|
|
378
|
+
const client = new ClientConnection({
|
|
379
|
+
url: `ws://localhost:${testServer.port}`,
|
|
380
|
+
wsFactory: bunWebSocketFactory,
|
|
381
|
+
receiveProtocol: serverProtocol,
|
|
382
|
+
sendProtocol: clientProtocol,
|
|
383
|
+
})
|
|
384
|
+
await client.connect()
|
|
385
|
+
clients.push(client)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// All clients should be connected
|
|
389
|
+
expect(clients.every((c) => c.isConnected())).toBe(true)
|
|
390
|
+
|
|
391
|
+
// Each client sends a notification
|
|
392
|
+
clients.forEach((client, i) => {
|
|
393
|
+
client.notify.userMessage({ sessionId: `sess-${i}`, content: `Message from client ${i}` })
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
// Wait for notifications to arrive
|
|
397
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
398
|
+
|
|
399
|
+
// All notifications should be received
|
|
400
|
+
expect(testServer.receivedNotifications.length).toBe(numClients)
|
|
401
|
+
|
|
402
|
+
// Disconnect all
|
|
403
|
+
await Promise.all(clients.map((c) => c.disconnect()))
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
describe('Bidirectional Communication', () => {
|
|
408
|
+
it('should handle simultaneous send and receive', async () => {
|
|
409
|
+
const clientReceivedMessages: unknown[] = []
|
|
410
|
+
|
|
411
|
+
const client = new ClientConnection({
|
|
412
|
+
url: `ws://localhost:${testServer.port}`,
|
|
413
|
+
wsFactory: bunWebSocketFactory,
|
|
414
|
+
receiveProtocol: serverProtocol,
|
|
415
|
+
sendProtocol: clientProtocol,
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
client.setHandlers({
|
|
419
|
+
agentMessage: async (input) => {
|
|
420
|
+
clientReceivedMessages.push(input)
|
|
421
|
+
},
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
await client.connect()
|
|
425
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
426
|
+
|
|
427
|
+
// Client sends notification
|
|
428
|
+
client.notify.userMessage({ sessionId: 'sess-1', content: 'Client message' })
|
|
429
|
+
|
|
430
|
+
// Server sends notification simultaneously
|
|
431
|
+
const serverConnections = Array.from(testServer.connections.values())
|
|
432
|
+
serverConnections[0].notify.agentMessage({ sessionId: 'sess-1', content: 'Server message' })
|
|
433
|
+
|
|
434
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
435
|
+
|
|
436
|
+
// Both sides should have received notifications
|
|
437
|
+
expect(testServer.receivedNotifications.some((n) => n.type === 'userMessage')).toBe(true)
|
|
438
|
+
expect(clientReceivedMessages.length).toBe(1)
|
|
439
|
+
|
|
440
|
+
await client.disconnect()
|
|
441
|
+
})
|
|
442
|
+
})
|
|
443
|
+
})
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client Connection
|
|
3
|
+
*
|
|
4
|
+
* WebSocket client with automatic reconnection.
|
|
5
|
+
* Simplified: no handshake - connected immediately when WebSocket opens.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Connection } from '../core/connection.js'
|
|
9
|
+
import type { ConnectionConfig } from '../core/connection.js'
|
|
10
|
+
import type { ProtocolDef } from '../core/protocol.js'
|
|
11
|
+
import type { ReconnectOptions } from '../core/types.js'
|
|
12
|
+
import { DEFAULT_RECONNECT_OPTIONS } from '../core/types.js'
|
|
13
|
+
import type { IWebSocketFactory } from '../platform/types.js'
|
|
14
|
+
import { WebSocketReadyState } from '../platform/types.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Client connection configuration.
|
|
18
|
+
*/
|
|
19
|
+
export interface ClientConnectionConfig<TReceive extends ProtocolDef, TSend extends ProtocolDef> extends ConnectionConfig<TReceive, TSend> {
|
|
20
|
+
url: string
|
|
21
|
+
wsFactory: IWebSocketFactory
|
|
22
|
+
reconnect?: Partial<ReconnectOptions>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* WebSocket client with automatic reconnection and exponential backoff.
|
|
27
|
+
*/
|
|
28
|
+
export class ClientConnection<TReceive extends ProtocolDef, TSend extends ProtocolDef> extends Connection<TReceive, TSend> {
|
|
29
|
+
private readonly url: string
|
|
30
|
+
private readonly wsFactory: IWebSocketFactory
|
|
31
|
+
|
|
32
|
+
// Reconnection
|
|
33
|
+
private readonly reconnectOptions: ReconnectOptions | null
|
|
34
|
+
private reconnectAttempts = 0
|
|
35
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
36
|
+
private reconnectStopped = false
|
|
37
|
+
|
|
38
|
+
// Connect promise
|
|
39
|
+
private connectPromise: Promise<void> | null = null
|
|
40
|
+
private connectResolve: (() => void) | null = null
|
|
41
|
+
private connectReject: ((error: Error) => void) | null = null
|
|
42
|
+
private intentionalDisconnect = false
|
|
43
|
+
|
|
44
|
+
constructor(config: ClientConnectionConfig<TReceive, TSend>) {
|
|
45
|
+
super(config)
|
|
46
|
+
this.url = config.url
|
|
47
|
+
this.wsFactory = config.wsFactory
|
|
48
|
+
this.reconnectOptions = config.reconnect
|
|
49
|
+
? { ...DEFAULT_RECONNECT_OPTIONS, ...config.reconnect }
|
|
50
|
+
: null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async connect(): Promise<void> {
|
|
54
|
+
if (this.isConnected()) return
|
|
55
|
+
if (this.connectPromise) return this.connectPromise
|
|
56
|
+
|
|
57
|
+
this.intentionalDisconnect = false
|
|
58
|
+
this.reconnectStopped = false
|
|
59
|
+
|
|
60
|
+
this.connectPromise = new Promise<void>((resolve, reject) => {
|
|
61
|
+
this.connectResolve = resolve
|
|
62
|
+
this.connectReject = reject
|
|
63
|
+
this.doConnect()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return this.connectPromise
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async disconnect(): Promise<void> {
|
|
70
|
+
this.intentionalDisconnect = true
|
|
71
|
+
this.stopReconnect()
|
|
72
|
+
this.cleanup()
|
|
73
|
+
|
|
74
|
+
if (this.ws && this.ws.readyState !== WebSocketReadyState.CLOSED) {
|
|
75
|
+
this.ws.close(1000, 'Client disconnect')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.ws = null
|
|
79
|
+
this.setState('disconnected')
|
|
80
|
+
|
|
81
|
+
if (this.connectReject) {
|
|
82
|
+
this.connectReject(new Error('Disconnected'))
|
|
83
|
+
this.clearConnectPromise()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getReconnectAttempts(): number {
|
|
88
|
+
return this.reconnectAttempts
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
hasReconnect(): boolean {
|
|
92
|
+
return this.reconnectOptions !== null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Override to resolve connect promise and reset reconnect
|
|
96
|
+
protected override handleOpen(): void {
|
|
97
|
+
const wasReconnecting = this.reconnectAttempts > 0
|
|
98
|
+
super.handleOpen()
|
|
99
|
+
this.reconnectAttempts = 0
|
|
100
|
+
|
|
101
|
+
// Flush any messages buffered during disconnect
|
|
102
|
+
this.flushSendBuffer()
|
|
103
|
+
|
|
104
|
+
if (this.connectResolve) {
|
|
105
|
+
this.connectResolve()
|
|
106
|
+
this.clearConnectPromise()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Emit reconnected event so application can restore subscriptions
|
|
110
|
+
if (wasReconnecting) {
|
|
111
|
+
this.emit('reconnected')
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
protected override handleClose(code: number, reason: string): void {
|
|
116
|
+
this.cleanup()
|
|
117
|
+
|
|
118
|
+
if (this.intentionalDisconnect) {
|
|
119
|
+
this.setState('disconnected')
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (this.connectReject) {
|
|
124
|
+
this.connectReject(new Error(`Connection closed: ${code} ${reason}`))
|
|
125
|
+
this.clearConnectPromise()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.handleConnectionLost()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
protected override handleConnectionLost(): void {
|
|
132
|
+
this.cleanup()
|
|
133
|
+
|
|
134
|
+
if (this.intentionalDisconnect) {
|
|
135
|
+
this.setState('disconnected')
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!this.reconnectOptions || this.reconnectStopped) {
|
|
140
|
+
this.setState('disconnected')
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (this.reconnectAttempts >= this.reconnectOptions.maxAttempts) {
|
|
145
|
+
this.emit('error', new Error('Max reconnection attempts reached'))
|
|
146
|
+
this.setState('disconnected')
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
this.setState('reconnecting')
|
|
151
|
+
const delay = this.calculateReconnectDelay()
|
|
152
|
+
this.reconnectAttempts++
|
|
153
|
+
|
|
154
|
+
this.emit('reconnecting', { delay, attempt: this.reconnectAttempts })
|
|
155
|
+
|
|
156
|
+
this.reconnectTimer = setTimeout(() => {
|
|
157
|
+
this.reconnectTimer = null
|
|
158
|
+
this.setState('connecting')
|
|
159
|
+
this.doConnect()
|
|
160
|
+
}, delay)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private doConnect(): void {
|
|
164
|
+
this.setState('connecting')
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const ws = this.wsFactory.create(this.url)
|
|
168
|
+
this.setupWebSocket(ws)
|
|
169
|
+
ws.onopen = () => this.handleOpen()
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (this.connectReject) {
|
|
172
|
+
this.connectReject(error instanceof Error ? error : new Error('Connection failed'))
|
|
173
|
+
this.clearConnectPromise()
|
|
174
|
+
}
|
|
175
|
+
this.handleConnectionLost()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private calculateReconnectDelay(): number {
|
|
180
|
+
if (!this.reconnectOptions) return 0
|
|
181
|
+
const { baseDelayMs, maxDelayMs, jitterFactor } = this.reconnectOptions
|
|
182
|
+
const exponential = baseDelayMs * Math.pow(2, this.reconnectAttempts)
|
|
183
|
+
const capped = Math.min(exponential, maxDelayMs)
|
|
184
|
+
const jitter = capped * jitterFactor * Math.random()
|
|
185
|
+
return Math.floor(capped + jitter)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private stopReconnect(): void {
|
|
189
|
+
this.reconnectStopped = true
|
|
190
|
+
if (this.reconnectTimer) {
|
|
191
|
+
clearTimeout(this.reconnectTimer)
|
|
192
|
+
this.reconnectTimer = null
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private clearConnectPromise(): void {
|
|
197
|
+
this.connectPromise = null
|
|
198
|
+
this.connectResolve = null
|
|
199
|
+
this.connectReject = null
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function createClientConnection<TReceive extends ProtocolDef, TSend extends ProtocolDef>(
|
|
204
|
+
config: ClientConnectionConfig<TReceive, TSend>,
|
|
205
|
+
): ClientConnection<TReceive, TSend> {
|
|
206
|
+
return new ClientConnection(config)
|
|
207
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client Connection Module
|
|
3
|
+
*
|
|
4
|
+
* WebSocket client for connecting to server endpoints.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { ClientConnection, createClientConnection } from './client-connection.js'
|
|
8
|
+
export type { ClientConnectionConfig } from './client-connection.js'
|
|
9
|
+
|
|
10
|
+
// Re-export core types commonly used with client
|
|
11
|
+
export type {
|
|
12
|
+
ConnectionState,
|
|
13
|
+
HandlerContext,
|
|
14
|
+
Protocol,
|
|
15
|
+
ProtocolCaller,
|
|
16
|
+
ProtocolDef,
|
|
17
|
+
ProtocolHandlers,
|
|
18
|
+
ProtocolNotifier,
|
|
19
|
+
ReconnectOptions,
|
|
20
|
+
TransportEvent,
|
|
21
|
+
TransportEventListener,
|
|
22
|
+
} from '../core/index.js'
|
|
23
|
+
|
|
24
|
+
export { defineProtocol, method, notification } from '../core/index.js'
|