@agentick/gateway 0.0.1
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/LICENSE +21 -0
- package/README.md +477 -0
- package/dist/agent-registry.d.ts +51 -0
- package/dist/agent-registry.d.ts.map +1 -0
- package/dist/agent-registry.js +78 -0
- package/dist/agent-registry.js.map +1 -0
- package/dist/app-registry.d.ts +51 -0
- package/dist/app-registry.d.ts.map +1 -0
- package/dist/app-registry.js +78 -0
- package/dist/app-registry.js.map +1 -0
- package/dist/bin.d.ts +8 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +37 -0
- package/dist/bin.js.map +1 -0
- package/dist/gateway.d.ts +165 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +1339 -0
- package/dist/gateway.js.map +1 -0
- package/dist/http-transport.d.ts +65 -0
- package/dist/http-transport.d.ts.map +1 -0
- package/dist/http-transport.js +517 -0
- package/dist/http-transport.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +162 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +16 -0
- package/dist/protocol.js.map +1 -0
- package/dist/session-manager.d.ts +101 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +208 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/testing.d.ts +92 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +129 -0
- package/dist/testing.js.map +1 -0
- package/dist/transport-protocol.d.ts +162 -0
- package/dist/transport-protocol.d.ts.map +1 -0
- package/dist/transport-protocol.js +16 -0
- package/dist/transport-protocol.js.map +1 -0
- package/dist/transport.d.ts +115 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +56 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +314 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +37 -0
- package/dist/types.js.map +1 -0
- package/dist/websocket-server.d.ts +87 -0
- package/dist/websocket-server.d.ts.map +1 -0
- package/dist/websocket-server.js +245 -0
- package/dist/websocket-server.js.map +1 -0
- package/dist/ws-transport.d.ts +17 -0
- package/dist/ws-transport.d.ts.map +1 -0
- package/dist/ws-transport.js +174 -0
- package/dist/ws-transport.js.map +1 -0
- package/package.json +51 -0
- package/src/__tests__/custom-methods.spec.ts +220 -0
- package/src/__tests__/gateway-methods.spec.ts +262 -0
- package/src/__tests__/gateway.spec.ts +404 -0
- package/src/__tests__/guards.spec.ts +235 -0
- package/src/__tests__/protocol.spec.ts +58 -0
- package/src/__tests__/session-manager.spec.ts +220 -0
- package/src/__tests__/ws-transport.spec.ts +246 -0
- package/src/app-registry.ts +103 -0
- package/src/bin.ts +38 -0
- package/src/gateway.ts +1712 -0
- package/src/http-transport.ts +623 -0
- package/src/index.ts +94 -0
- package/src/session-manager.ts +272 -0
- package/src/testing.ts +236 -0
- package/src/transport-protocol.ts +249 -0
- package/src/transport.ts +191 -0
- package/src/types.ts +392 -0
- package/src/websocket-server.ts +303 -0
- package/src/ws-transport.ts +205 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway Types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { App } from "@agentick/core";
|
|
6
|
+
import type { KernelContext, UserContext } from "@agentick/kernel";
|
|
7
|
+
import type { AuthConfig } from "@agentick/server";
|
|
8
|
+
|
|
9
|
+
// Re-export auth types from server
|
|
10
|
+
export type { AuthConfig, AuthResult } from "@agentick/server";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Schema type that works with both Zod 3 and Zod 4.
|
|
14
|
+
* We only need parse() and type inference (_output).
|
|
15
|
+
*/
|
|
16
|
+
export interface ZodLikeSchema<T = unknown> {
|
|
17
|
+
parse(data: unknown): T;
|
|
18
|
+
_output: T;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type { UserContext } from "@agentick/kernel";
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Gateway Configuration
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
export interface GatewayConfig {
|
|
28
|
+
/**
|
|
29
|
+
* Port to listen on (ignored in embedded mode)
|
|
30
|
+
* @default 18789
|
|
31
|
+
*/
|
|
32
|
+
port?: number;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Host to bind to (ignored in embedded mode)
|
|
36
|
+
* @default "127.0.0.1"
|
|
37
|
+
*/
|
|
38
|
+
host?: string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Gateway ID (auto-generated if not provided)
|
|
42
|
+
*/
|
|
43
|
+
id?: string;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* App definitions
|
|
47
|
+
*/
|
|
48
|
+
apps: Record<string, App>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Default app to use when session key doesn't specify one
|
|
52
|
+
*/
|
|
53
|
+
defaultApp: string;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Authentication configuration
|
|
57
|
+
*/
|
|
58
|
+
auth?: AuthConfig;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run in embedded mode (no standalone server).
|
|
62
|
+
* Use handleRequest() to process requests from your framework.
|
|
63
|
+
* @default false
|
|
64
|
+
*/
|
|
65
|
+
embedded?: boolean;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Persistence configuration
|
|
69
|
+
*/
|
|
70
|
+
storage?: StorageConfig;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Channel adapters (WhatsApp, Slack, etc.)
|
|
74
|
+
*/
|
|
75
|
+
channels?: ChannelAdapter[];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Message routing configuration
|
|
79
|
+
*/
|
|
80
|
+
routing?: RoutingConfig;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Transport mode (ignored in embedded mode)
|
|
84
|
+
* - "websocket": WebSocket only (default, good for CLI/native clients)
|
|
85
|
+
* - "http": HTTP/SSE only (good for web browsers)
|
|
86
|
+
* - "both": Both transports on different ports
|
|
87
|
+
* @default "websocket"
|
|
88
|
+
*/
|
|
89
|
+
transport?: "websocket" | "http" | "both";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* HTTP path prefix (e.g., "/api")
|
|
93
|
+
* @default ""
|
|
94
|
+
*/
|
|
95
|
+
httpPathPrefix?: string;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* CORS origin for HTTP transport
|
|
99
|
+
* @default "*"
|
|
100
|
+
*/
|
|
101
|
+
httpCorsOrigin?: string;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* HTTP port when using "both" mode
|
|
105
|
+
* @default port + 1
|
|
106
|
+
*/
|
|
107
|
+
httpPort?: number;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Custom methods - runs within Agentick ALS context.
|
|
111
|
+
*
|
|
112
|
+
* Supports:
|
|
113
|
+
* - Simple handlers: `async (params) => result`
|
|
114
|
+
* - Streaming: `async function* (params) { yield value }`
|
|
115
|
+
* - With config: `method({ schema, handler, roles, guard })`
|
|
116
|
+
* - Namespaces: `{ tasks: { list, create, admin: { ... } } }` (recursive)
|
|
117
|
+
*
|
|
118
|
+
* Use method() wrapper for schema validation, roles, guards, etc.
|
|
119
|
+
* ctx param is optional - use Context.get() for idiomatic access.
|
|
120
|
+
*/
|
|
121
|
+
methods?: MethodsConfig;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================================
|
|
125
|
+
// Storage
|
|
126
|
+
// ============================================================================
|
|
127
|
+
|
|
128
|
+
export interface StorageConfig {
|
|
129
|
+
/**
|
|
130
|
+
* Base directory for storage
|
|
131
|
+
* @default "~/.agentick"
|
|
132
|
+
*/
|
|
133
|
+
directory?: string;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Enable session persistence
|
|
137
|
+
* @default true
|
|
138
|
+
*/
|
|
139
|
+
sessions?: boolean;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Enable memory persistence
|
|
143
|
+
* @default true
|
|
144
|
+
*/
|
|
145
|
+
memory?: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// Channels
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
export interface ChannelAdapter {
|
|
153
|
+
/**
|
|
154
|
+
* Channel identifier
|
|
155
|
+
*/
|
|
156
|
+
id: string;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Human-readable name
|
|
160
|
+
*/
|
|
161
|
+
name: string;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Initialize the channel
|
|
165
|
+
*/
|
|
166
|
+
initialize(gateway: GatewayContext): Promise<void>;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Clean up resources
|
|
170
|
+
*/
|
|
171
|
+
destroy(): Promise<void>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface GatewayContext {
|
|
175
|
+
/**
|
|
176
|
+
* Send a message to a session
|
|
177
|
+
*/
|
|
178
|
+
sendToSession(sessionId: string, message: string): Promise<void>;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get available apps
|
|
182
|
+
*/
|
|
183
|
+
getApps(): string[];
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get or create a session
|
|
187
|
+
*/
|
|
188
|
+
getSession(sessionId: string): SessionContext;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface SessionContext {
|
|
192
|
+
id: string;
|
|
193
|
+
appId: string;
|
|
194
|
+
send(message: string): AsyncGenerator<SessionEvent>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface SessionEvent {
|
|
198
|
+
type: string;
|
|
199
|
+
data: unknown;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ============================================================================
|
|
203
|
+
// Routing
|
|
204
|
+
// ============================================================================
|
|
205
|
+
|
|
206
|
+
export interface RoutingConfig {
|
|
207
|
+
/**
|
|
208
|
+
* Map channels to agents
|
|
209
|
+
*/
|
|
210
|
+
channels?: Record<string, string>;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Custom routing function
|
|
214
|
+
*/
|
|
215
|
+
custom?: (message: IncomingMessage, context: RoutingContext) => string | null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface IncomingMessage {
|
|
219
|
+
text: string;
|
|
220
|
+
channel?: string;
|
|
221
|
+
from?: string;
|
|
222
|
+
metadata?: Record<string, unknown>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface RoutingContext {
|
|
226
|
+
availableApps: string[];
|
|
227
|
+
defaultApp: string;
|
|
228
|
+
sessionHistory?: Array<{ role: string; content: string }>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ============================================================================
|
|
232
|
+
// Client State
|
|
233
|
+
// ============================================================================
|
|
234
|
+
|
|
235
|
+
export interface ClientState {
|
|
236
|
+
id: string;
|
|
237
|
+
connectedAt: Date;
|
|
238
|
+
authenticated: boolean;
|
|
239
|
+
/** Full user context from auth */
|
|
240
|
+
user?: UserContext;
|
|
241
|
+
subscriptions: Set<string>;
|
|
242
|
+
metadata?: Record<string, unknown>;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ============================================================================
|
|
246
|
+
// Session State
|
|
247
|
+
// ============================================================================
|
|
248
|
+
|
|
249
|
+
export interface SessionState {
|
|
250
|
+
id: string;
|
|
251
|
+
appId: string;
|
|
252
|
+
createdAt: Date;
|
|
253
|
+
lastActivityAt: Date;
|
|
254
|
+
messageCount: number;
|
|
255
|
+
isActive: boolean;
|
|
256
|
+
subscribers: Set<string>;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ============================================================================
|
|
260
|
+
// Events
|
|
261
|
+
// ============================================================================
|
|
262
|
+
|
|
263
|
+
export interface GatewayEvents {
|
|
264
|
+
started: { port: number; host: string };
|
|
265
|
+
stopped: Record<string, never>;
|
|
266
|
+
"client:connected": { clientId: string; ip?: string };
|
|
267
|
+
"client:disconnected": { clientId: string; reason?: string };
|
|
268
|
+
"client:authenticated": { clientId: string; user?: UserContext };
|
|
269
|
+
"session:created": { sessionId: string; appId: string };
|
|
270
|
+
"session:closed": { sessionId: string };
|
|
271
|
+
"session:message": {
|
|
272
|
+
sessionId: string;
|
|
273
|
+
role: "user" | "assistant";
|
|
274
|
+
content: string;
|
|
275
|
+
};
|
|
276
|
+
"app:message": {
|
|
277
|
+
appId: string;
|
|
278
|
+
sessionId: string;
|
|
279
|
+
message: string;
|
|
280
|
+
};
|
|
281
|
+
"channel:message": {
|
|
282
|
+
channel: string;
|
|
283
|
+
from: string;
|
|
284
|
+
message: string;
|
|
285
|
+
};
|
|
286
|
+
"channel:error": {
|
|
287
|
+
channel: string;
|
|
288
|
+
error: Error;
|
|
289
|
+
};
|
|
290
|
+
error: Error;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ============================================================================
|
|
294
|
+
// Custom Methods
|
|
295
|
+
// ============================================================================
|
|
296
|
+
|
|
297
|
+
/** Symbol for detecting method definitions vs namespaces */
|
|
298
|
+
export const METHOD_DEFINITION = Symbol.for("agentick:method-definition");
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Simple method handler - ctx param is optional since Context.get() works
|
|
302
|
+
*/
|
|
303
|
+
export type SimpleMethodHandler<TParams = Record<string, unknown>, TResult = unknown> = (
|
|
304
|
+
params: TParams,
|
|
305
|
+
ctx?: KernelContext,
|
|
306
|
+
) => Promise<TResult> | TResult;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Streaming method handler - yields values to client
|
|
310
|
+
*/
|
|
311
|
+
export type StreamingMethodHandler<TParams = Record<string, unknown>, TYield = unknown> = (
|
|
312
|
+
params: TParams,
|
|
313
|
+
ctx?: KernelContext,
|
|
314
|
+
) => AsyncGenerator<TYield>;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Method definition input (what you pass to method())
|
|
318
|
+
*/
|
|
319
|
+
export interface MethodDefinitionInput<TSchema extends ZodLikeSchema = ZodLikeSchema> {
|
|
320
|
+
/** Zod schema for params validation + TypeScript inference */
|
|
321
|
+
schema?: TSchema;
|
|
322
|
+
/** Handler function - receives validated & typed params */
|
|
323
|
+
handler: SimpleMethodHandler<TSchema["_output"]> | StreamingMethodHandler<TSchema["_output"]>;
|
|
324
|
+
/** Required roles - checked before handler */
|
|
325
|
+
roles?: string[];
|
|
326
|
+
/** Custom guard function */
|
|
327
|
+
guard?: (ctx: KernelContext) => boolean | Promise<boolean>;
|
|
328
|
+
/** Method description for discovery */
|
|
329
|
+
description?: string;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Method definition with symbol marker (returned by method())
|
|
334
|
+
*/
|
|
335
|
+
export interface MethodDefinition<
|
|
336
|
+
TSchema extends ZodLikeSchema = ZodLikeSchema,
|
|
337
|
+
> extends MethodDefinitionInput<TSchema> {
|
|
338
|
+
[METHOD_DEFINITION]: true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Factory function to create a method definition.
|
|
343
|
+
* Stores config (schema, roles, guards) - the gateway creates the
|
|
344
|
+
* actual procedure during initialization with the full inferred path name.
|
|
345
|
+
*
|
|
346
|
+
* @example
|
|
347
|
+
* methods: {
|
|
348
|
+
* tasks: {
|
|
349
|
+
* list: async (params) => { ... }, // Simple - auto-wrapped
|
|
350
|
+
* create: method({ // With config
|
|
351
|
+
* schema: z.object({ title: z.string() }),
|
|
352
|
+
* handler: async (params) => { ... }
|
|
353
|
+
* }),
|
|
354
|
+
* }
|
|
355
|
+
* }
|
|
356
|
+
*/
|
|
357
|
+
export function method<TSchema extends ZodLikeSchema>(
|
|
358
|
+
definition: MethodDefinitionInput<TSchema>,
|
|
359
|
+
): MethodDefinition<TSchema> {
|
|
360
|
+
return {
|
|
361
|
+
[METHOD_DEFINITION]: true,
|
|
362
|
+
...definition,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Check if a value is a method definition (vs a namespace)
|
|
368
|
+
*/
|
|
369
|
+
export function isMethodDefinition(value: unknown): value is MethodDefinition {
|
|
370
|
+
return typeof value === "object" && value !== null && METHOD_DEFINITION in value;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Method can be:
|
|
375
|
+
* - Simple function: async (params) => result
|
|
376
|
+
* - Streaming function: async function* (params) { yield }
|
|
377
|
+
* - Method definition: method({ schema, handler, roles, ... })
|
|
378
|
+
*/
|
|
379
|
+
|
|
380
|
+
export type Method = SimpleMethodHandler | StreamingMethodHandler | MethodDefinition<any>;
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Method namespace - recursively nested, arbitrary depth
|
|
384
|
+
*/
|
|
385
|
+
export type MethodNamespace = {
|
|
386
|
+
[key: string]: Method | MethodNamespace;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Methods config - supports flat or nested namespaces
|
|
391
|
+
*/
|
|
392
|
+
export type MethodsConfig = MethodNamespace;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Server
|
|
3
|
+
*
|
|
4
|
+
* Handles WebSocket connections and message routing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
8
|
+
import type { IncomingMessage } from "http";
|
|
9
|
+
import type {
|
|
10
|
+
ClientMessage,
|
|
11
|
+
GatewayMessage,
|
|
12
|
+
ConnectMessage,
|
|
13
|
+
RequestMessage,
|
|
14
|
+
} from "./transport-protocol.js";
|
|
15
|
+
import type { ClientState, AuthConfig, AuthResult } from "./types.js";
|
|
16
|
+
|
|
17
|
+
export interface WSServerConfig {
|
|
18
|
+
port: number;
|
|
19
|
+
host: string;
|
|
20
|
+
auth?: AuthConfig;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface WSServerEvents {
|
|
24
|
+
connection: (client: WSClient) => void;
|
|
25
|
+
disconnect: (clientId: string, reason?: string) => void;
|
|
26
|
+
message: (clientId: string, message: ClientMessage) => void;
|
|
27
|
+
error: (error: Error) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class WSClient {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly socket: WebSocket;
|
|
33
|
+
readonly state: ClientState;
|
|
34
|
+
private server: WSServer;
|
|
35
|
+
|
|
36
|
+
constructor(id: string, socket: WebSocket, server: WSServer) {
|
|
37
|
+
this.id = id;
|
|
38
|
+
this.socket = socket;
|
|
39
|
+
this.server = server;
|
|
40
|
+
this.state = {
|
|
41
|
+
id,
|
|
42
|
+
connectedAt: new Date(),
|
|
43
|
+
authenticated: false,
|
|
44
|
+
subscriptions: new Set(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Send a message to this client
|
|
50
|
+
*/
|
|
51
|
+
send(message: GatewayMessage): void {
|
|
52
|
+
if (this.socket.readyState === WebSocket.OPEN) {
|
|
53
|
+
this.socket.send(JSON.stringify(message));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Close the connection
|
|
59
|
+
*/
|
|
60
|
+
close(code?: number, reason?: string): void {
|
|
61
|
+
this.socket.close(code, reason);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if connected
|
|
66
|
+
*/
|
|
67
|
+
get isConnected(): boolean {
|
|
68
|
+
return this.socket.readyState === WebSocket.OPEN;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class WSServer {
|
|
73
|
+
private wss: WebSocketServer | null = null;
|
|
74
|
+
private clients = new Map<string, WSClient>();
|
|
75
|
+
private config: WSServerConfig;
|
|
76
|
+
private handlers: Partial<WSServerEvents> = {};
|
|
77
|
+
private clientIdCounter = 0;
|
|
78
|
+
|
|
79
|
+
constructor(config: WSServerConfig) {
|
|
80
|
+
this.config = config;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Start the WebSocket server
|
|
85
|
+
*/
|
|
86
|
+
start(): Promise<void> {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
try {
|
|
89
|
+
this.wss = new WebSocketServer({
|
|
90
|
+
port: this.config.port,
|
|
91
|
+
host: this.config.host,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
this.wss.on("connection", this.handleConnection.bind(this));
|
|
95
|
+
this.wss.on("error", (error) => {
|
|
96
|
+
this.handlers.error?.(error);
|
|
97
|
+
reject(error);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
this.wss.on("listening", () => {
|
|
101
|
+
resolve();
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
reject(error);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Stop the server
|
|
111
|
+
*/
|
|
112
|
+
stop(): Promise<void> {
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
if (!this.wss) {
|
|
115
|
+
resolve();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Close all client connections
|
|
120
|
+
for (const client of this.clients.values()) {
|
|
121
|
+
client.close(1001, "Server shutting down");
|
|
122
|
+
}
|
|
123
|
+
this.clients.clear();
|
|
124
|
+
|
|
125
|
+
// Close the server
|
|
126
|
+
this.wss.close(() => {
|
|
127
|
+
this.wss = null;
|
|
128
|
+
resolve();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Register event handlers
|
|
135
|
+
*/
|
|
136
|
+
on<K extends keyof WSServerEvents>(event: K, handler: WSServerEvents[K]): void {
|
|
137
|
+
this.handlers[event] = handler;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get a client by ID
|
|
142
|
+
*/
|
|
143
|
+
getClient(id: string): WSClient | undefined {
|
|
144
|
+
return this.clients.get(id);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get all clients
|
|
149
|
+
*/
|
|
150
|
+
getClients(): WSClient[] {
|
|
151
|
+
return Array.from(this.clients.values());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Get authenticated clients
|
|
156
|
+
*/
|
|
157
|
+
getAuthenticatedClients(): WSClient[] {
|
|
158
|
+
return this.getClients().filter((c) => c.state.authenticated);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Broadcast a message to all authenticated clients
|
|
163
|
+
*/
|
|
164
|
+
broadcast(message: GatewayMessage): void {
|
|
165
|
+
for (const client of this.getAuthenticatedClients()) {
|
|
166
|
+
client.send(message);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Send a message to clients subscribed to a session
|
|
172
|
+
*/
|
|
173
|
+
sendToSubscribers(sessionId: string, message: GatewayMessage): void {
|
|
174
|
+
for (const client of this.getAuthenticatedClients()) {
|
|
175
|
+
if (client.state.subscriptions.has(sessionId)) {
|
|
176
|
+
client.send(message);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get connected client count
|
|
183
|
+
*/
|
|
184
|
+
get clientCount(): number {
|
|
185
|
+
return this.clients.size;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private handleConnection(socket: WebSocket, _request: IncomingMessage): void {
|
|
189
|
+
const clientId = `client-${++this.clientIdCounter}`;
|
|
190
|
+
const client = new WSClient(clientId, socket, this);
|
|
191
|
+
this.clients.set(clientId, client);
|
|
192
|
+
|
|
193
|
+
socket.on("message", (data) => {
|
|
194
|
+
try {
|
|
195
|
+
const message = JSON.parse(data.toString()) as ClientMessage;
|
|
196
|
+
this.handleMessage(client, message);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
client.send({
|
|
199
|
+
type: "error",
|
|
200
|
+
code: "INVALID_MESSAGE",
|
|
201
|
+
message: "Failed to parse message",
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
socket.on("close", () => {
|
|
207
|
+
this.clients.delete(clientId);
|
|
208
|
+
this.handlers.disconnect?.(clientId);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
socket.on("error", (error) => {
|
|
212
|
+
this.handlers.error?.(error);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Notify handler of new connection (before auth)
|
|
216
|
+
this.handlers.connection?.(client);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async handleMessage(client: WSClient, message: ClientMessage): Promise<void> {
|
|
220
|
+
// Handle connect message (authentication)
|
|
221
|
+
if (message.type === "connect") {
|
|
222
|
+
await this.handleConnect(client, message);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Handle ping
|
|
227
|
+
if (message.type === "ping") {
|
|
228
|
+
client.send({ type: "pong", timestamp: message.timestamp });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// All other messages require authentication
|
|
233
|
+
if (!client.state.authenticated) {
|
|
234
|
+
client.send({
|
|
235
|
+
type: "error",
|
|
236
|
+
code: "UNAUTHORIZED",
|
|
237
|
+
message: "Authentication required. Send connect message first.",
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Forward to message handler
|
|
243
|
+
this.handlers.message?.(client.id, message);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async handleConnect(client: WSClient, message: ConnectMessage): Promise<void> {
|
|
247
|
+
// Validate authentication
|
|
248
|
+
const authResult = await this.validateAuth(message.token);
|
|
249
|
+
|
|
250
|
+
if (!authResult.valid) {
|
|
251
|
+
client.send({
|
|
252
|
+
type: "error",
|
|
253
|
+
code: "AUTH_FAILED",
|
|
254
|
+
message: "Authentication failed",
|
|
255
|
+
});
|
|
256
|
+
client.close(4001, "Authentication failed");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Update client state
|
|
261
|
+
client.state.authenticated = true;
|
|
262
|
+
client.state.user = authResult.user;
|
|
263
|
+
client.state.metadata = {
|
|
264
|
+
...client.state.metadata,
|
|
265
|
+
...authResult.metadata,
|
|
266
|
+
...message.metadata,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Client ID from message takes precedence
|
|
270
|
+
if (message.clientId) {
|
|
271
|
+
// Update internal tracking if client provides their own ID
|
|
272
|
+
this.clients.delete(client.id);
|
|
273
|
+
(client as { id: string }).id = message.clientId;
|
|
274
|
+
this.clients.set(message.clientId, client);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private async validateAuth(token?: string): Promise<AuthResult> {
|
|
279
|
+
const auth = this.config.auth;
|
|
280
|
+
|
|
281
|
+
// No auth configured
|
|
282
|
+
if (!auth || auth.type === "none") {
|
|
283
|
+
return { valid: true };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Token auth
|
|
287
|
+
if (auth.type === "token") {
|
|
288
|
+
return { valid: token === auth.token };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// JWT auth
|
|
292
|
+
if (auth.type === "jwt") {
|
|
293
|
+
return { valid: false };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Custom auth
|
|
297
|
+
if (auth.type === "custom") {
|
|
298
|
+
return await auth.validate(token ?? "");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { valid: false };
|
|
302
|
+
}
|
|
303
|
+
}
|