@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.
Files changed (64) hide show
  1. package/dist/client/client-connection.d.ts +48 -0
  2. package/dist/client/client-connection.d.ts.map +1 -0
  3. package/dist/client/client-connection.js +160 -0
  4. package/dist/client/index.d.ts +10 -0
  5. package/dist/client/index.d.ts.map +1 -0
  6. package/dist/client/index.js +7 -0
  7. package/dist/core/connection.d.ts +59 -0
  8. package/dist/core/connection.d.ts.map +1 -0
  9. package/dist/core/connection.js +165 -0
  10. package/dist/core/index.d.ts +16 -0
  11. package/dist/core/index.d.ts.map +1 -0
  12. package/dist/core/index.js +13 -0
  13. package/dist/core/protocol.d.ts +206 -0
  14. package/dist/core/protocol.d.ts.map +1 -0
  15. package/dist/core/protocol.js +93 -0
  16. package/dist/core/result.d.ts +26 -0
  17. package/dist/core/result.d.ts.map +1 -0
  18. package/dist/core/result.js +20 -0
  19. package/dist/core/router.d.ts +79 -0
  20. package/dist/core/router.d.ts.map +1 -0
  21. package/dist/core/router.js +119 -0
  22. package/dist/core/types.d.ts +44 -0
  23. package/dist/core/types.d.ts.map +1 -0
  24. package/dist/core/types.js +14 -0
  25. package/dist/index.d.ts +24 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +26 -0
  28. package/dist/platform/browser.d.ts +14 -0
  29. package/dist/platform/browser.d.ts.map +1 -0
  30. package/dist/platform/browser.js +65 -0
  31. package/dist/platform/bun.d.ts +69 -0
  32. package/dist/platform/bun.d.ts.map +1 -0
  33. package/dist/platform/bun.js +152 -0
  34. package/dist/platform/types.d.ts +83 -0
  35. package/dist/platform/types.d.ts.map +1 -0
  36. package/dist/platform/types.js +14 -0
  37. package/dist/server/connection-manager.d.ts +106 -0
  38. package/dist/server/connection-manager.d.ts.map +1 -0
  39. package/dist/server/connection-manager.js +234 -0
  40. package/dist/server/index.d.ts +12 -0
  41. package/dist/server/index.d.ts.map +1 -0
  42. package/dist/server/index.js +8 -0
  43. package/dist/server/server-connection.d.ts +50 -0
  44. package/dist/server/server-connection.d.ts.map +1 -0
  45. package/dist/server/server-connection.js +84 -0
  46. package/package.json +52 -0
  47. package/src/__tests__/integration.test.ts +443 -0
  48. package/src/client/client-connection.ts +207 -0
  49. package/src/client/index.ts +24 -0
  50. package/src/core/connection.ts +203 -0
  51. package/src/core/index.ts +52 -0
  52. package/src/core/protocol.test.ts +235 -0
  53. package/src/core/protocol.ts +348 -0
  54. package/src/core/result.ts +35 -0
  55. package/src/core/router.test.ts +248 -0
  56. package/src/core/router.ts +186 -0
  57. package/src/core/types.ts +85 -0
  58. package/src/index.ts +80 -0
  59. package/src/platform/browser.ts +79 -0
  60. package/src/platform/bun.ts +220 -0
  61. package/src/platform/types.ts +102 -0
  62. package/src/server/connection-manager.ts +298 -0
  63. package/src/server/index.ts +26 -0
  64. package/src/server/server-connection.ts +122 -0
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Connection Base Class
3
+ *
4
+ * Provides symmetric API for WebSocket client and server connections.
5
+ * Simplified: no handshake, no heartbeat - just WebSocket open/close → connected/disconnected.
6
+ * Notification-only: no request-response, no correlation.
7
+ */
8
+
9
+ import type { IWebSocket } from '../platform/types.js'
10
+ import { WebSocketReadyState } from '../platform/types.js'
11
+ import type { Protocol, ProtocolDef, ProtocolHandlers, ProtocolNotifier } from './protocol.js'
12
+ import { MessageRouter } from './router.js'
13
+ import type { RawMessageListener } from './router.js'
14
+ import type { ConnectionState, TransportEvent, TransportEventListener } from './types.js'
15
+
16
+ /**
17
+ * Connection configuration.
18
+ */
19
+ export interface ConnectionConfig<TReceive extends ProtocolDef, TSend extends ProtocolDef> {
20
+ receiveProtocol?: Protocol<TReceive>
21
+ sendProtocol?: Protocol<TSend>
22
+ }
23
+
24
+ /**
25
+ * Abstract base class for WebSocket connections.
26
+ * State transitions: disconnected ↔ connecting ↔ connected ↔ reconnecting
27
+ */
28
+ export abstract class Connection<TReceive extends ProtocolDef, TSend extends ProtocolDef> {
29
+ protected readonly config: ConnectionConfig<TReceive, TSend>
30
+ protected readonly router: MessageRouter<TReceive, TSend>
31
+
32
+ // State
33
+ protected state: ConnectionState = 'disconnected'
34
+ protected ws: IWebSocket | null = null
35
+ protected connectionId: string
36
+ protected eventListeners = new Set<TransportEventListener>()
37
+
38
+ constructor(config: ConnectionConfig<TReceive, TSend>) {
39
+ this.config = config
40
+ this.connectionId = `conn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
41
+
42
+ this.router = new MessageRouter(
43
+ config.receiveProtocol ?? null,
44
+ config.sendProtocol ?? null,
45
+ )
46
+ this.router.setErrorHandler((error) => this.emit('router_error', error))
47
+ }
48
+
49
+ // ============================================================================
50
+ // Public API
51
+ // ============================================================================
52
+
53
+ getState(): ConnectionState {
54
+ return this.state
55
+ }
56
+
57
+ isConnected(): boolean {
58
+ return this.state === 'connected'
59
+ }
60
+
61
+ getConnectionId(): string {
62
+ return this.connectionId
63
+ }
64
+
65
+ setHandlers(handlers: Partial<ProtocolHandlers<TReceive>>): void {
66
+ this.router.setHandlers(handlers)
67
+ }
68
+
69
+ /**
70
+ * Set a raw message listener that receives every incoming message as (type, payload).
71
+ * When set, bypasses per-type handler dispatch.
72
+ */
73
+ setRawMessageListener(listener: RawMessageListener): void {
74
+ this.router.setRawMessageListener(listener)
75
+ }
76
+
77
+ get notify(): ProtocolNotifier<TSend> {
78
+ const protocol = this.config.sendProtocol
79
+ if (!protocol) {
80
+ throw new Error('Cannot send notifications without a send protocol')
81
+ }
82
+ const notifier: Record<string, unknown> = {}
83
+ for (const name of protocol.getNotificationNames()) {
84
+ notifier[name as string] = (input: unknown) => this.router.notify(name, input as never)
85
+ }
86
+ return notifier as ProtocolNotifier<TSend>
87
+ }
88
+
89
+ // ============================================================================
90
+ // Send Buffer (bridges short disconnects)
91
+ // ============================================================================
92
+
93
+ private sendBuffer: string[] = []
94
+ private readonly maxBufferSize: number = 500
95
+
96
+ send(data: string): boolean {
97
+ if (!this.ws || this.ws.readyState !== WebSocketReadyState.OPEN) {
98
+ if (this.sendBuffer.length < this.maxBufferSize) {
99
+ this.sendBuffer.push(data)
100
+ }
101
+ return false
102
+ }
103
+ this.flushSendBuffer()
104
+ try {
105
+ this.ws.send(data)
106
+ return true
107
+ } catch {
108
+ if (this.sendBuffer.length < this.maxBufferSize) {
109
+ this.sendBuffer.push(data)
110
+ }
111
+ return false
112
+ }
113
+ }
114
+
115
+ flushSendBuffer(): void {
116
+ while (this.sendBuffer.length > 0) {
117
+ if (!this.ws || this.ws.readyState !== WebSocketReadyState.OPEN) break
118
+ const msg = this.sendBuffer.shift()!
119
+ try {
120
+ this.ws.send(msg)
121
+ } catch {
122
+ this.sendBuffer.unshift(msg)
123
+ break
124
+ }
125
+ }
126
+ }
127
+
128
+ clearSendBuffer(): void {
129
+ this.sendBuffer.length = 0
130
+ }
131
+
132
+ get bufferedMessageCount(): number {
133
+ return this.sendBuffer.length
134
+ }
135
+
136
+ on(listener: TransportEventListener): () => void {
137
+ this.eventListeners.add(listener)
138
+ return () => this.eventListeners.delete(listener)
139
+ }
140
+
141
+ abstract connect(): Promise<void>
142
+ abstract disconnect(): Promise<void>
143
+
144
+ // ============================================================================
145
+ // State Management
146
+ // ============================================================================
147
+
148
+ protected setState(newState: ConnectionState): void {
149
+ if (newState === this.state) return
150
+ const from = this.state
151
+ this.state = newState
152
+ this.emit('state_change', { from, to: newState })
153
+ if (newState === 'connected') this.emit('connected')
154
+ else if (newState === 'disconnected') this.emit('disconnected')
155
+ else if (newState === 'reconnecting') this.emit('reconnecting')
156
+ }
157
+
158
+ // ============================================================================
159
+ // WebSocket Setup
160
+ // ============================================================================
161
+
162
+ protected setupWebSocket(ws: IWebSocket): void {
163
+ this.ws = ws
164
+ this.router.setSender((message) => this.send(message))
165
+ ws.onmessage = (data) => this.router.handleMessage(data, { connectionId: this.connectionId })
166
+ ws.onerror = (error) => this.emit('error', error)
167
+ ws.onclose = (event) => this.handleClose(event.code, event.reason)
168
+ }
169
+
170
+ protected handleOpen(): void {
171
+ // Immediately connected - no handshake needed
172
+ this.setState('connected')
173
+ }
174
+
175
+ protected handleClose(_code: number, _reason: string): void {
176
+ this.cleanup()
177
+ this.setState('disconnected')
178
+ }
179
+
180
+ protected handleConnectionLost(): void {
181
+ this.cleanup()
182
+ this.setState('disconnected')
183
+ }
184
+
185
+ protected cleanup(): void {
186
+ if (this.ws) {
187
+ this.ws.onopen = null
188
+ this.ws.onclose = null
189
+ this.ws.onerror = null
190
+ this.ws.onmessage = null
191
+ }
192
+ }
193
+
194
+ protected emit(event: TransportEvent, data?: unknown): void {
195
+ for (const listener of this.eventListeners) {
196
+ try {
197
+ listener(event, data)
198
+ } catch {
199
+ // Ignore listener errors
200
+ }
201
+ }
202
+ }
203
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Core Transport Module
3
+ *
4
+ * Platform-agnostic WebSocket infrastructure.
5
+ */
6
+
7
+ // Types
8
+ export type { ConnectionState, ReconnectOptions, TransportEvent, TransportEventListener, WireMessage } from './types.js'
9
+
10
+ export { DEFAULT_RECONNECT_OPTIONS } from './types.js'
11
+
12
+ // Result
13
+ export type { Result } from './result.js'
14
+ export { Err, flatMapResult, isErr, isOk, mapResult, Ok, unwrapOr, unwrapOrThrow } from './result.js'
15
+
16
+ // Protocol Definition
17
+ export { defineProtocol, method, notification } from './protocol.js'
18
+ export type {
19
+ CallerFor,
20
+ EndpointDef,
21
+ EndpointHandler,
22
+ ExtractProtocolDef,
23
+ HandlerContext,
24
+ HandlersFor,
25
+ InferError,
26
+ InferInput,
27
+ InferOutput,
28
+ IsMethod,
29
+ IsNotification,
30
+ MethodCaller,
31
+ MethodDef,
32
+ MethodHandler,
33
+ MethodOptions,
34
+ NotificationDef,
35
+ NotificationHandler,
36
+ NotificationNotifier,
37
+ NotificationOptions,
38
+ NotifierFor,
39
+ Protocol,
40
+ ProtocolCaller,
41
+ ProtocolDef,
42
+ ProtocolHandlers,
43
+ ProtocolNotifier,
44
+ } from './protocol.js'
45
+
46
+ // Message Router
47
+ export { createRouter, MessageRouter } from './router.js'
48
+ export type { MessageSender } from './router.js'
49
+
50
+ // Connection Base
51
+ export { Connection } from './connection.js'
52
+ export type { ConnectionConfig } from './connection.js'
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Protocol Definition Tests
3
+ */
4
+
5
+ import { describe, expect, it } from 'bun:test'
6
+ import { z } from 'zod'
7
+ import { defineProtocol, method, notification } from './protocol.js'
8
+
9
+ describe('Protocol Definition', () => {
10
+ describe('method()', () => {
11
+ it('should create a method definition', () => {
12
+ const def = method({
13
+ input: z.object({ id: z.string() }),
14
+ output: z.object({ success: z.boolean() }),
15
+ error: z.object({ code: z.string() }),
16
+ })
17
+
18
+ expect(def._type).toBe('method')
19
+ expect(def.input).toBeDefined()
20
+ expect(def.output).toBeDefined()
21
+ expect(def.error).toBeDefined()
22
+ })
23
+ })
24
+
25
+ describe('notification()', () => {
26
+ it('should create a notification definition', () => {
27
+ const def = notification({
28
+ input: z.object({ message: z.string() }),
29
+ })
30
+
31
+ expect(def._type).toBe('notification')
32
+ expect(def.input).toBeDefined()
33
+ })
34
+ })
35
+
36
+ describe('defineProtocol()', () => {
37
+ const testProtocol = defineProtocol({
38
+ subscribe: method({
39
+ input: z.object({ sessionId: z.string() }),
40
+ output: z.void(),
41
+ error: z.object({ code: z.enum(['SESSION_NOT_FOUND']), message: z.string() }),
42
+ }),
43
+ unsubscribe: method({
44
+ input: z.object({ sessionId: z.string() }),
45
+ output: z.void(),
46
+ error: z.never(),
47
+ }),
48
+ agentMessage: notification({
49
+ input: z.object({
50
+ sessionId: z.string(),
51
+ content: z.string(),
52
+ format: z.enum(['text', 'markdown']),
53
+ }),
54
+ }),
55
+ })
56
+
57
+ it('should expose the definition', () => {
58
+ expect(testProtocol._def).toBeDefined()
59
+ expect(testProtocol._def.subscribe).toBeDefined()
60
+ expect(testProtocol._def.unsubscribe).toBeDefined()
61
+ expect(testProtocol._def.agentMessage).toBeDefined()
62
+ })
63
+
64
+ describe('getEndpoint()', () => {
65
+ it('should return endpoint definition by name', () => {
66
+ const endpoint = testProtocol.getEndpoint('subscribe')
67
+ expect(endpoint._type).toBe('method')
68
+ })
69
+ })
70
+
71
+ describe('isMethod()', () => {
72
+ it('should return true for methods', () => {
73
+ expect(testProtocol.isMethod('subscribe')).toBe(true)
74
+ expect(testProtocol.isMethod('unsubscribe')).toBe(true)
75
+ })
76
+
77
+ it('should return false for notifications', () => {
78
+ expect(testProtocol.isMethod('agentMessage')).toBe(false)
79
+ })
80
+ })
81
+
82
+ describe('isNotification()', () => {
83
+ it('should return true for notifications', () => {
84
+ expect(testProtocol.isNotification('agentMessage')).toBe(true)
85
+ })
86
+
87
+ it('should return false for methods', () => {
88
+ expect(testProtocol.isNotification('subscribe')).toBe(false)
89
+ })
90
+ })
91
+
92
+ describe('getMethodNames()', () => {
93
+ it('should return all method names', () => {
94
+ const names = testProtocol.getMethodNames()
95
+ expect(names).toContain('subscribe')
96
+ expect(names).toContain('unsubscribe')
97
+ expect(names).not.toContain('agentMessage')
98
+ })
99
+ })
100
+
101
+ describe('getNotificationNames()', () => {
102
+ it('should return all notification names', () => {
103
+ const names = testProtocol.getNotificationNames()
104
+ expect(names).toContain('agentMessage')
105
+ expect(names).not.toContain('subscribe')
106
+ })
107
+ })
108
+
109
+ describe('validateInput()', () => {
110
+ it('should validate correct input', () => {
111
+ const result = testProtocol.validateInput('subscribe', { sessionId: 'test-123' })
112
+ expect(result.success).toBe(true)
113
+ if (result.success) {
114
+ expect(result.data.sessionId).toBe('test-123')
115
+ }
116
+ })
117
+
118
+ it('should reject invalid input', () => {
119
+ const result = testProtocol.validateInput('subscribe', { sessionId: 123 })
120
+ expect(result.success).toBe(false)
121
+ if (!result.success) {
122
+ expect(result.error.message).toBeDefined()
123
+ }
124
+ })
125
+
126
+ it('should reject missing required fields', () => {
127
+ const result = testProtocol.validateInput('subscribe', {})
128
+ expect(result.success).toBe(false)
129
+ })
130
+
131
+ it('should validate notification input', () => {
132
+ const result = testProtocol.validateInput('agentMessage', {
133
+ sessionId: 'test',
134
+ content: 'Hello',
135
+ format: 'text',
136
+ })
137
+ expect(result.success).toBe(true)
138
+ })
139
+
140
+ it('should reject invalid notification input', () => {
141
+ const result = testProtocol.validateInput('agentMessage', {
142
+ sessionId: 'test',
143
+ content: 'Hello',
144
+ format: 'invalid',
145
+ })
146
+ expect(result.success).toBe(false)
147
+ })
148
+ })
149
+
150
+ describe('validateOutput()', () => {
151
+ it('should validate method output', () => {
152
+ const result = testProtocol.validateOutput('subscribe', undefined)
153
+ expect(result.success).toBe(true)
154
+ })
155
+
156
+ it('should throw for notification output validation', () => {
157
+ expect(() => testProtocol.validateOutput('agentMessage', {})).toThrow(
158
+ 'Endpoint agentMessage is not a method',
159
+ )
160
+ })
161
+ })
162
+ })
163
+
164
+ describe('complex schemas', () => {
165
+ it('should handle nested objects', () => {
166
+ const protocol = defineProtocol({
167
+ createUser: method({
168
+ input: z.object({
169
+ name: z.string(),
170
+ address: z.object({
171
+ street: z.string(),
172
+ city: z.string(),
173
+ }),
174
+ }),
175
+ output: z.object({ id: z.string() }),
176
+ error: z.object({ code: z.string() }),
177
+ }),
178
+ })
179
+
180
+ const valid = protocol.validateInput('createUser', {
181
+ name: 'John',
182
+ address: { street: '123 Main St', city: 'NYC' },
183
+ })
184
+ expect(valid.success).toBe(true)
185
+
186
+ const invalid = protocol.validateInput('createUser', {
187
+ name: 'John',
188
+ address: { street: '123 Main St' }, // Missing city
189
+ })
190
+ expect(invalid.success).toBe(false)
191
+ })
192
+
193
+ it('should handle arrays', () => {
194
+ const protocol = defineProtocol({
195
+ sendBatch: method({
196
+ input: z.object({
197
+ messages: z.array(z.object({
198
+ id: z.string(),
199
+ content: z.string(),
200
+ })),
201
+ }),
202
+ output: z.object({ processed: z.number() }),
203
+ error: z.object({ code: z.string() }),
204
+ }),
205
+ })
206
+
207
+ const valid = protocol.validateInput('sendBatch', {
208
+ messages: [
209
+ { id: '1', content: 'Hello' },
210
+ { id: '2', content: 'World' },
211
+ ],
212
+ })
213
+ expect(valid.success).toBe(true)
214
+ })
215
+
216
+ it('should handle optional fields', () => {
217
+ const protocol = defineProtocol({
218
+ search: method({
219
+ input: z.object({
220
+ query: z.string(),
221
+ limit: z.number().optional(),
222
+ }),
223
+ output: z.array(z.string()),
224
+ error: z.object({ code: z.string() }),
225
+ }),
226
+ })
227
+
228
+ const withOptional = protocol.validateInput('search', { query: 'test', limit: 10 })
229
+ expect(withOptional.success).toBe(true)
230
+
231
+ const withoutOptional = protocol.validateInput('search', { query: 'test' })
232
+ expect(withoutOptional.success).toBe(true)
233
+ })
234
+ })
235
+ })