@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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +477 -0
  3. package/dist/agent-registry.d.ts +51 -0
  4. package/dist/agent-registry.d.ts.map +1 -0
  5. package/dist/agent-registry.js +78 -0
  6. package/dist/agent-registry.js.map +1 -0
  7. package/dist/app-registry.d.ts +51 -0
  8. package/dist/app-registry.d.ts.map +1 -0
  9. package/dist/app-registry.js +78 -0
  10. package/dist/app-registry.js.map +1 -0
  11. package/dist/bin.d.ts +8 -0
  12. package/dist/bin.d.ts.map +1 -0
  13. package/dist/bin.js +37 -0
  14. package/dist/bin.js.map +1 -0
  15. package/dist/gateway.d.ts +165 -0
  16. package/dist/gateway.d.ts.map +1 -0
  17. package/dist/gateway.js +1339 -0
  18. package/dist/gateway.js.map +1 -0
  19. package/dist/http-transport.d.ts +65 -0
  20. package/dist/http-transport.d.ts.map +1 -0
  21. package/dist/http-transport.js +517 -0
  22. package/dist/http-transport.js.map +1 -0
  23. package/dist/index.d.ts +16 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +23 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/protocol.d.ts +162 -0
  28. package/dist/protocol.d.ts.map +1 -0
  29. package/dist/protocol.js +16 -0
  30. package/dist/protocol.js.map +1 -0
  31. package/dist/session-manager.d.ts +101 -0
  32. package/dist/session-manager.d.ts.map +1 -0
  33. package/dist/session-manager.js +208 -0
  34. package/dist/session-manager.js.map +1 -0
  35. package/dist/testing.d.ts +92 -0
  36. package/dist/testing.d.ts.map +1 -0
  37. package/dist/testing.js +129 -0
  38. package/dist/testing.js.map +1 -0
  39. package/dist/transport-protocol.d.ts +162 -0
  40. package/dist/transport-protocol.d.ts.map +1 -0
  41. package/dist/transport-protocol.js +16 -0
  42. package/dist/transport-protocol.js.map +1 -0
  43. package/dist/transport.d.ts +115 -0
  44. package/dist/transport.d.ts.map +1 -0
  45. package/dist/transport.js +56 -0
  46. package/dist/transport.js.map +1 -0
  47. package/dist/types.d.ts +314 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +37 -0
  50. package/dist/types.js.map +1 -0
  51. package/dist/websocket-server.d.ts +87 -0
  52. package/dist/websocket-server.d.ts.map +1 -0
  53. package/dist/websocket-server.js +245 -0
  54. package/dist/websocket-server.js.map +1 -0
  55. package/dist/ws-transport.d.ts +17 -0
  56. package/dist/ws-transport.d.ts.map +1 -0
  57. package/dist/ws-transport.js +174 -0
  58. package/dist/ws-transport.js.map +1 -0
  59. package/package.json +51 -0
  60. package/src/__tests__/custom-methods.spec.ts +220 -0
  61. package/src/__tests__/gateway-methods.spec.ts +262 -0
  62. package/src/__tests__/gateway.spec.ts +404 -0
  63. package/src/__tests__/guards.spec.ts +235 -0
  64. package/src/__tests__/protocol.spec.ts +58 -0
  65. package/src/__tests__/session-manager.spec.ts +220 -0
  66. package/src/__tests__/ws-transport.spec.ts +246 -0
  67. package/src/app-registry.ts +103 -0
  68. package/src/bin.ts +38 -0
  69. package/src/gateway.ts +1712 -0
  70. package/src/http-transport.ts +623 -0
  71. package/src/index.ts +94 -0
  72. package/src/session-manager.ts +272 -0
  73. package/src/testing.ts +236 -0
  74. package/src/transport-protocol.ts +249 -0
  75. package/src/transport.ts +191 -0
  76. package/src/types.ts +392 -0
  77. package/src/websocket-server.ts +303 -0
  78. package/src/ws-transport.ts +205 -0
package/src/gateway.ts ADDED
@@ -0,0 +1,1712 @@
1
+ /**
2
+ * Gateway
3
+ *
4
+ * Standalone daemon for multi-client, multi-app access.
5
+ * Transport-agnostic: supports both WebSocket and HTTP/SSE.
6
+ *
7
+ * Can run standalone or embedded in an external framework.
8
+ */
9
+
10
+ import { EventEmitter } from "events";
11
+ import type { IncomingMessage as NodeRequest, ServerResponse as NodeResponse } from "http";
12
+ import type { Message } from "@agentick/shared";
13
+ import { GuardError, isGuardError } from "@agentick/shared";
14
+ import {
15
+ devToolsEmitter,
16
+ type DTClientConnectedEvent,
17
+ type DTClientDisconnectedEvent,
18
+ type DTGatewayRequestEvent,
19
+ type DTGatewayResponseEvent,
20
+ } from "@agentick/shared";
21
+ import {
22
+ Context,
23
+ createProcedure,
24
+ createGuard,
25
+ Logger,
26
+ type KernelContext,
27
+ type Procedure,
28
+ type Middleware,
29
+ type UserContext,
30
+ type ChannelServiceInterface,
31
+ type ChannelEvent,
32
+ } from "@agentick/kernel";
33
+ import type { Session } from "@agentick/core";
34
+
35
+ const log = Logger.for("Gateway");
36
+ import { extractToken, validateAuth, setSSEHeaders, type AuthResult } from "@agentick/server";
37
+ import { AppRegistry } from "./app-registry.js";
38
+ import { SessionManager } from "./session-manager.js";
39
+ import { WSTransport } from "./ws-transport.js";
40
+ import { HTTPTransport } from "./http-transport.js";
41
+ import type { Transport, TransportClient } from "./transport.js";
42
+ import type {
43
+ GatewayConfig,
44
+ GatewayEvents,
45
+ GatewayContext,
46
+ SessionEvent,
47
+ MethodNamespace,
48
+ } from "./types.js";
49
+ import { isMethodDefinition } from "./types.js";
50
+ import type {
51
+ RequestMessage,
52
+ GatewayMethod,
53
+ GatewayEventType,
54
+ SendParams,
55
+ StatusParams,
56
+ HistoryParams,
57
+ SubscribeParams,
58
+ StatusPayload,
59
+ AppsPayload,
60
+ SessionsPayload,
61
+ } from "./transport-protocol.js";
62
+
63
+ const DEFAULT_PORT = 18789;
64
+ const DEFAULT_HOST = "127.0.0.1";
65
+
66
+ // ============================================================================
67
+ // Guard Middleware
68
+ // ============================================================================
69
+
70
+ /** Guard middleware that checks user roles */
71
+ function createRoleGuardMiddleware(roles: string[]): Middleware<any[]> {
72
+ return createGuard({ name: "gateway-role", guardType: "role" }, () => {
73
+ const userRoles = Context.get().user?.roles ?? [];
74
+ if (!roles.some((r) => userRoles.includes(r))) {
75
+ throw GuardError.role(roles);
76
+ }
77
+ return true;
78
+ });
79
+ }
80
+
81
+ /** Guard middleware that runs custom guard function */
82
+ function createCustomGuardMiddleware(
83
+ guard: (ctx: KernelContext) => boolean | Promise<boolean>,
84
+ ): Middleware<any[]> {
85
+ return createGuard({ name: "gateway-custom", reason: "Guard check failed" }, () =>
86
+ guard(Context.get()),
87
+ );
88
+ }
89
+
90
+ // ============================================================================
91
+ // Channel Service Helpers
92
+ // ============================================================================
93
+
94
+ /**
95
+ * Create a ChannelServiceInterface that wraps a Session's channel() method.
96
+ * This allows gateway methods to access session channels via Context.
97
+ */
98
+ function createChannelServiceFromSession(
99
+ session: Session,
100
+ _gatewayId: string,
101
+ ): ChannelServiceInterface {
102
+ return {
103
+ getChannel: (_ctx: KernelContext, channelName: string) => session.channel(channelName),
104
+ publish: (_ctx: KernelContext, channelName: string, event: Omit<ChannelEvent, "channel">) => {
105
+ session.channel(channelName).publish({ ...event, channel: channelName } as ChannelEvent);
106
+ },
107
+ subscribe: (
108
+ _ctx: KernelContext,
109
+ channelName: string,
110
+ handler: (event: ChannelEvent) => void,
111
+ ) => {
112
+ return session.channel(channelName).subscribe(handler);
113
+ },
114
+ waitForResponse: (
115
+ _ctx: KernelContext,
116
+ channelName: string,
117
+ requestId: string,
118
+ timeoutMs?: number,
119
+ ) => {
120
+ return session.channel(channelName).waitForResponse(requestId, timeoutMs);
121
+ },
122
+ };
123
+ }
124
+
125
+ // ============================================================================
126
+ // Gateway Class
127
+ // ============================================================================
128
+
129
+ /** Built-in methods that cannot be overridden */
130
+ const BUILT_IN_METHODS: Set<string> = new Set([
131
+ "send",
132
+ "abort",
133
+ "status",
134
+ "history",
135
+ "reset",
136
+ "close",
137
+ "apps",
138
+ "sessions",
139
+ "subscribe",
140
+ "unsubscribe",
141
+ ]);
142
+
143
+ export class Gateway extends EventEmitter {
144
+ private config: Required<
145
+ Pick<GatewayConfig, "port" | "host" | "id" | "defaultApp" | "transport">
146
+ > &
147
+ GatewayConfig;
148
+ private registry: AppRegistry;
149
+ private sessions: SessionManager;
150
+ private transports: Transport[] = [];
151
+ private startTime: Date | null = null;
152
+ private isRunning = false;
153
+ private embedded: boolean;
154
+
155
+ /** Pre-compiled map of method paths to procedures */
156
+ private methodProcedures = new Map<string, Procedure<any>>();
157
+
158
+ /** Track open SSE connections for embedded mode */
159
+ private sseClients = new Map<string, NodeResponse>();
160
+
161
+ /** Track channel subscriptions: "sessionId:channelName" -> Set of clientIds */
162
+ private channelSubscriptions = new Map<string, Set<string>>();
163
+
164
+ /** Track unsubscribe functions for core session channels */
165
+ private coreChannelUnsubscribes = new Map<string, () => void>();
166
+
167
+ /** Track client connection times for duration calculation */
168
+ private clientConnectedAt = new Map<string, number>();
169
+
170
+ /** Sequence counter for DevTools events */
171
+ private devToolsSequence = 0;
172
+
173
+ constructor(config: GatewayConfig) {
174
+ super();
175
+
176
+ // Validate config
177
+ if (!config.apps || Object.keys(config.apps).length === 0) {
178
+ throw new Error("At least one app is required");
179
+ }
180
+ if (!config.defaultApp) {
181
+ throw new Error("defaultApp is required");
182
+ }
183
+
184
+ this.embedded = config.embedded ?? false;
185
+
186
+ // Set defaults
187
+ this.config = {
188
+ ...config,
189
+ port: config.port ?? DEFAULT_PORT,
190
+ host: config.host ?? DEFAULT_HOST,
191
+ id: config.id ?? `gw-${Date.now().toString(36)}`,
192
+ transport: config.transport ?? "websocket",
193
+ };
194
+
195
+ // Initialize components
196
+ this.registry = new AppRegistry(config.apps, config.defaultApp);
197
+ this.sessions = new SessionManager(this.registry, { gatewayId: this.config.id });
198
+
199
+ // Initialize all methods as procedures
200
+ if (config.methods) {
201
+ this.initializeMethods(config.methods, []);
202
+ }
203
+
204
+ // Create transports only in standalone mode
205
+ if (!this.embedded) {
206
+ this.initializeTransports();
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Walk the methods tree and wrap all handlers as procedures.
212
+ * Infers full path name (e.g., "tasks:admin:archive") automatically.
213
+ */
214
+ private initializeMethods(methods: MethodNamespace, path: string[]): void {
215
+ for (const [key, value] of Object.entries(methods)) {
216
+ const fullPath = [...path, key];
217
+ const methodName = fullPath.join(":"); // e.g., "tasks:admin:archive"
218
+
219
+ if (typeof value === "function") {
220
+ // Simple function -> wrap in procedure automatically
221
+ this.methodProcedures.set(
222
+ methodName,
223
+ createProcedure(
224
+ {
225
+ name: `gateway:${methodName}`,
226
+ executionBoundary: "auto",
227
+ metadata: { gatewayId: this.config.id, method: methodName },
228
+ },
229
+ value as (...args: any[]) => any,
230
+ ),
231
+ );
232
+ } else if (isMethodDefinition(value)) {
233
+ // method() definition -> create procedure with guards/schema as middleware
234
+ const middleware: Middleware<any[]>[] = [];
235
+
236
+ if (value.roles?.length) {
237
+ middleware.push(createRoleGuardMiddleware(value.roles));
238
+ }
239
+ if (value.guard) {
240
+ middleware.push(createCustomGuardMiddleware(value.guard));
241
+ }
242
+
243
+ this.methodProcedures.set(
244
+ methodName,
245
+ createProcedure(
246
+ {
247
+ name: `gateway:${methodName}`,
248
+ executionBoundary: "auto",
249
+ // Cast to any for Zod 3/4 compatibility - runtime uses .parse() only
250
+ schema: value.schema as any,
251
+ middleware,
252
+ metadata: {
253
+ gatewayId: this.config.id,
254
+ method: methodName,
255
+ description: value.description,
256
+ roles: value.roles,
257
+ },
258
+ },
259
+ value.handler as (...args: any[]) => any,
260
+ ),
261
+ );
262
+ } else {
263
+ // Plain object -> namespace, recurse
264
+ this.initializeMethods(value as MethodNamespace, fullPath);
265
+ }
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Get a method's procedure by path (supports both ":" and "." separators)
271
+ */
272
+ private getMethodProcedure(path: string): Procedure<any> | undefined {
273
+ // Normalize separators to ":"
274
+ const normalized = path.replace(/\./g, ":");
275
+ return this.methodProcedures.get(normalized);
276
+ }
277
+
278
+ private initializeTransports(): void {
279
+ const { transport, port, host, auth, httpPort } = this.config;
280
+
281
+ if (transport === "websocket" || transport === "both") {
282
+ const wsTransport = new WSTransport({ port, host, auth });
283
+ this.setupTransportHandlers(wsTransport);
284
+ this.transports.push(wsTransport);
285
+ }
286
+
287
+ if (transport === "http" || transport === "both") {
288
+ const httpTransportPort = transport === "both" ? (httpPort ?? port + 1) : port;
289
+ const httpTransportInstance = new HTTPTransport({
290
+ port: httpTransportPort,
291
+ host,
292
+ auth,
293
+ pathPrefix: this.config.httpPathPrefix,
294
+ corsOrigin: this.config.httpCorsOrigin,
295
+ onDirectSend: this.directSend.bind(this),
296
+ onInvoke: this.invokeMethod.bind(this),
297
+ });
298
+ this.setupTransportHandlers(httpTransportInstance);
299
+ this.transports.push(httpTransportInstance);
300
+ }
301
+ }
302
+
303
+ private setupTransportHandlers(transport: Transport): void {
304
+ transport.on("connection", (client) => {
305
+ // Track connection time for duration calculation
306
+ const connectTime = Date.now();
307
+ this.clientConnectedAt.set(client.id, connectTime);
308
+
309
+ this.emit("client:connected", {
310
+ clientId: client.id,
311
+ });
312
+
313
+ // Emit DevTools event
314
+ if (devToolsEmitter.hasSubscribers()) {
315
+ devToolsEmitter.emitEvent({
316
+ type: "client_connected",
317
+ executionId: this.config.id,
318
+ clientId: client.id,
319
+ transport: transport.type as "websocket" | "sse" | "http",
320
+ sequence: this.devToolsSequence++,
321
+ timestamp: connectTime,
322
+ } as DTClientConnectedEvent);
323
+ }
324
+ });
325
+
326
+ transport.on("disconnect", (clientId, reason) => {
327
+ // Calculate connection duration
328
+ const connectedAt = this.clientConnectedAt.get(clientId);
329
+ const durationMs = connectedAt ? Date.now() - connectedAt : 0;
330
+ this.clientConnectedAt.delete(clientId);
331
+
332
+ // Clean up subscriptions
333
+ this.sessions.unsubscribeAll(clientId);
334
+
335
+ this.emit("client:disconnected", {
336
+ clientId,
337
+ reason,
338
+ });
339
+
340
+ // Emit DevTools event
341
+ if (devToolsEmitter.hasSubscribers()) {
342
+ devToolsEmitter.emitEvent({
343
+ type: "client_disconnected",
344
+ executionId: this.config.id,
345
+ clientId,
346
+ reason,
347
+ durationMs,
348
+ sequence: this.devToolsSequence++,
349
+ timestamp: Date.now(),
350
+ } as DTClientDisconnectedEvent);
351
+ }
352
+ });
353
+
354
+ transport.on("message", async (clientId, message) => {
355
+ if (message.type === "req") {
356
+ await this.handleTransportRequest(transport, clientId, message);
357
+ }
358
+ });
359
+
360
+ transport.on("error", (error) => {
361
+ this.emit("error", error);
362
+ });
363
+ }
364
+
365
+ /**
366
+ * Start the gateway (standalone mode only)
367
+ */
368
+ async start(): Promise<void> {
369
+ if (this.embedded) {
370
+ throw new Error("Cannot call start() in embedded mode - use handleRequest() instead");
371
+ }
372
+
373
+ if (this.isRunning) {
374
+ throw new Error("Gateway is already running");
375
+ }
376
+
377
+ // Initialize channel adapters
378
+ if (this.config.channels) {
379
+ const context = this.createGatewayContext();
380
+ for (const channel of this.config.channels) {
381
+ await channel.initialize(context);
382
+ }
383
+ }
384
+
385
+ // Start all transports
386
+ await Promise.all(this.transports.map((t) => t.start()));
387
+
388
+ this.startTime = new Date();
389
+ this.isRunning = true;
390
+
391
+ this.emit("started", {
392
+ port: this.config.port,
393
+ host: this.config.host,
394
+ });
395
+ }
396
+
397
+ /**
398
+ * Stop the gateway
399
+ */
400
+ async stop(): Promise<void> {
401
+ if (!this.isRunning && !this.embedded) return;
402
+
403
+ // Destroy channel adapters
404
+ if (this.config.channels) {
405
+ for (const channel of this.config.channels) {
406
+ await channel.destroy();
407
+ }
408
+ }
409
+
410
+ // Stop all transports (if any)
411
+ await Promise.all(this.transports.map((t) => t.stop()));
412
+
413
+ this.isRunning = false;
414
+ this.startTime = null;
415
+
416
+ this.emit("stopped", {});
417
+ }
418
+
419
+ /**
420
+ * Alias for stop() - useful for embedded mode cleanup
421
+ */
422
+ async close(): Promise<void> {
423
+ return this.stop();
424
+ }
425
+
426
+ /**
427
+ * Get gateway status
428
+ */
429
+ get status(): StatusPayload["gateway"] {
430
+ return {
431
+ id: this.config.id,
432
+ uptime: this.startTime ? Math.floor((Date.now() - this.startTime.getTime()) / 1000) : 0,
433
+ clients: this.transports.reduce((sum, t) => sum + t.clientCount, 0),
434
+ sessions: this.sessions.size,
435
+ apps: this.registry.ids(),
436
+ };
437
+ }
438
+
439
+ /**
440
+ * Check if running
441
+ */
442
+ get running(): boolean {
443
+ return this.isRunning;
444
+ }
445
+
446
+ /**
447
+ * Get the gateway ID
448
+ */
449
+ get id(): string {
450
+ return this.config.id;
451
+ }
452
+
453
+ // ══════════════════════════════════════════════════════════════════════════
454
+ // Embedded Mode: handleRequest()
455
+ // ══════════════════════════════════════════════════════════════════════════
456
+
457
+ /**
458
+ * Handle an HTTP request (embedded mode).
459
+ * This is the main entry point when Gateway is embedded in an external framework.
460
+ *
461
+ * @param req - Node.js IncomingMessage (or Express/Koa/etc request)
462
+ * @param res - Node.js ServerResponse (or Express/Koa/etc response)
463
+ * @returns Promise that resolves when request is handled (may reject on error)
464
+ *
465
+ * @example
466
+ * ```typescript
467
+ * // Express middleware
468
+ * app.use("/api", (req, res, next) => {
469
+ * gateway.handleRequest(req, res).catch(next);
470
+ * });
471
+ * ```
472
+ */
473
+ async handleRequest(req: NodeRequest, res: NodeResponse): Promise<void> {
474
+ // Set CORS headers
475
+ const origin = this.config.httpCorsOrigin ?? "*";
476
+ res.setHeader("Access-Control-Allow-Origin", origin);
477
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
478
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
479
+ res.setHeader("Access-Control-Allow-Credentials", "true");
480
+
481
+ // Handle preflight
482
+ if (req.method === "OPTIONS") {
483
+ res.writeHead(204);
484
+ res.end();
485
+ return;
486
+ }
487
+
488
+ // Extract path (handle Express mounting where path is already stripped)
489
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
490
+ const prefix = this.config.httpPathPrefix ?? "";
491
+ const path = url.pathname.replace(prefix, "") || "/";
492
+
493
+ log.debug({ method: req.method, url: req.url, path }, "handleRequest");
494
+
495
+ // Route requests - all framework-level endpoints
496
+ switch (path) {
497
+ case "/events":
498
+ return this.handleSSE(req, res);
499
+ case "/send":
500
+ return this.handleSend(req, res);
501
+ case "/invoke":
502
+ return this.handleInvoke(req, res);
503
+ case "/subscribe":
504
+ return this.handleSubscribe(req, res);
505
+ case "/abort":
506
+ return this.handleAbort(req, res);
507
+ case "/close":
508
+ return this.handleCloseEndpoint(req, res);
509
+ case "/channel":
510
+ case "/channel/subscribe":
511
+ case "/channel/publish":
512
+ return this.handleChannel(req, res);
513
+ }
514
+
515
+ res.writeHead(404, { "Content-Type": "application/json" });
516
+ res.end(JSON.stringify({ error: "Not found" }));
517
+ }
518
+
519
+ // ══════════════════════════════════════════════════════════════════════════
520
+ // HTTP Handlers (used by both handleRequest and HTTPTransport)
521
+ // ══════════════════════════════════════════════════════════════════════════
522
+
523
+ private async handleSSE(req: NodeRequest, res: NodeResponse): Promise<void> {
524
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
525
+ const token = extractToken(req) ?? url.searchParams.get("token") ?? undefined;
526
+
527
+ const authResult = await validateAuth(token, this.config.auth);
528
+ if (!authResult.valid) {
529
+ res.writeHead(401, { "Content-Type": "application/json" });
530
+ res.end(JSON.stringify({ error: "Authentication failed" }));
531
+ return;
532
+ }
533
+
534
+ // Setup SSE response
535
+ setSSEHeaders(res);
536
+
537
+ const clientId = url.searchParams.get("clientId") ?? `client-${Date.now().toString(36)}`;
538
+
539
+ // Register SSE client for channel forwarding
540
+ const connectTime = Date.now();
541
+ this.sseClients.set(clientId, res);
542
+ this.clientConnectedAt.set(clientId, connectTime);
543
+
544
+ // Emit DevTools event for connection tracking
545
+ if (devToolsEmitter.hasSubscribers()) {
546
+ devToolsEmitter.emitEvent({
547
+ type: "client_connected",
548
+ executionId: this.config.id,
549
+ clientId,
550
+ transport: "sse",
551
+ sequence: this.devToolsSequence++,
552
+ timestamp: connectTime,
553
+ } as DTClientConnectedEvent);
554
+ }
555
+
556
+ // Send connection confirmation
557
+ // Client expects type: "connection" to resolve the connection promise
558
+ const connectData = JSON.stringify({
559
+ type: "connection",
560
+ connectionId: clientId,
561
+ subscriptions: [],
562
+ });
563
+ res.write(`data: ${connectData}\n\n`);
564
+
565
+ // Keep connection alive with periodic heartbeat
566
+ const heartbeat = setInterval(() => {
567
+ res.write(":heartbeat\n\n");
568
+ }, 30000);
569
+
570
+ res.on("close", () => {
571
+ clearInterval(heartbeat);
572
+ this.sessions.unsubscribeAll(clientId);
573
+ this.sseClients.delete(clientId);
574
+ this.cleanupClientChannelSubscriptions(clientId);
575
+
576
+ // Emit DevTools event for disconnection tracking
577
+ const connectedAt = this.clientConnectedAt.get(clientId);
578
+ const durationMs = connectedAt ? Date.now() - connectedAt : 0;
579
+ this.clientConnectedAt.delete(clientId);
580
+
581
+ if (devToolsEmitter.hasSubscribers()) {
582
+ devToolsEmitter.emitEvent({
583
+ type: "client_disconnected",
584
+ executionId: this.config.id,
585
+ clientId,
586
+ reason: "Connection closed",
587
+ durationMs,
588
+ sequence: this.devToolsSequence++,
589
+ timestamp: Date.now(),
590
+ } as DTClientDisconnectedEvent);
591
+ }
592
+ });
593
+ }
594
+
595
+ /**
596
+ * Clean up channel subscriptions for a disconnected client.
597
+ */
598
+ private cleanupClientChannelSubscriptions(clientId: string): void {
599
+ for (const [key, clientIds] of this.channelSubscriptions.entries()) {
600
+ clientIds.delete(clientId);
601
+ // If no more subscribers for this session:channel, unsubscribe from core channel
602
+ if (clientIds.size === 0) {
603
+ this.channelSubscriptions.delete(key);
604
+ const unsubscribe = this.coreChannelUnsubscribes.get(key);
605
+ if (unsubscribe) {
606
+ unsubscribe();
607
+ this.coreChannelUnsubscribes.delete(key);
608
+ }
609
+ }
610
+ }
611
+ }
612
+
613
+ private async handleSend(req: NodeRequest, res: NodeResponse): Promise<void> {
614
+ log.debug({ method: req.method, url: req.url }, "handleSend: START");
615
+
616
+ if (req.method !== "POST") {
617
+ res.writeHead(405, { "Content-Type": "application/json" });
618
+ res.end(JSON.stringify({ error: "Method not allowed" }));
619
+ return;
620
+ }
621
+
622
+ const token = extractToken(req);
623
+ const authResult = await validateAuth(token, this.config.auth);
624
+ if (!authResult.valid) {
625
+ res.writeHead(401, { "Content-Type": "application/json" });
626
+ res.end(JSON.stringify({ error: "Authentication failed" }));
627
+ return;
628
+ }
629
+
630
+ const body = await this.parseBody(req);
631
+ log.debug({ body }, "handleSend: parsed body");
632
+ if (!body) {
633
+ res.writeHead(400, { "Content-Type": "application/json" });
634
+ res.end(JSON.stringify({ error: "Invalid request body" }));
635
+ return;
636
+ }
637
+
638
+ const sessionId = (body.sessionId as string) ?? "main";
639
+ const rawMessages = body.messages;
640
+ log.debug({ sessionId, hasMessages: !!rawMessages }, "handleSend: extracted params");
641
+
642
+ if (!Array.isArray(rawMessages) || rawMessages.length === 0) {
643
+ res.writeHead(400, { "Content-Type": "application/json" });
644
+ res.end(
645
+ JSON.stringify({
646
+ error: "Invalid message format. Expected { messages: Message[] }",
647
+ }),
648
+ );
649
+ return;
650
+ }
651
+
652
+ // Use the first message for the directSend path (single-message execution)
653
+ const rawMessage = rawMessages[0] as any;
654
+ const message = {
655
+ role: rawMessage.role as "user" | "assistant" | "system" | "tool" | "event",
656
+ content: rawMessage.content,
657
+ ...(rawMessage.id && { id: rawMessage.id }),
658
+ ...(rawMessage.metadata && { metadata: rawMessage.metadata }),
659
+ };
660
+
661
+ // Setup streaming response
662
+ setSSEHeaders(res);
663
+
664
+ try {
665
+ log.debug({ sessionId }, "handleSend: calling directSend");
666
+ const events = this.directSend(sessionId, message as Message);
667
+
668
+ for await (const event of events) {
669
+ log.debug({ eventType: event.type }, "handleSend: got event from directSend");
670
+ const sseData = {
671
+ type: event.type,
672
+ sessionId,
673
+ ...(event.data && typeof event.data === "object" ? event.data : {}),
674
+ };
675
+ res.write(`data: ${JSON.stringify(sseData)}\n\n`);
676
+ }
677
+
678
+ log.debug({ sessionId }, "handleSend: directSend complete, sending execution_end");
679
+ res.write(`data: ${JSON.stringify({ type: "execution_end", sessionId })}\n\n`);
680
+ } catch (error) {
681
+ const errorMessage = error instanceof Error ? error.message : String(error);
682
+ const errorStack = error instanceof Error ? error.stack : undefined;
683
+ console.error("[Gateway handleSend ERROR]", errorMessage, "\n", errorStack);
684
+ log.error({ errorMessage, errorStack, sessionId }, "handleSend: ERROR in directSend");
685
+ res.write(`data: ${JSON.stringify({ type: "error", error: errorMessage, sessionId })}\n\n`);
686
+ } finally {
687
+ res.end();
688
+ }
689
+ }
690
+
691
+ private async handleInvoke(req: NodeRequest, res: NodeResponse): Promise<void> {
692
+ if (req.method !== "POST") {
693
+ res.writeHead(405, { "Content-Type": "application/json" });
694
+ res.end(JSON.stringify({ error: "Method not allowed" }));
695
+ return;
696
+ }
697
+
698
+ const token = extractToken(req);
699
+ const authResult = await validateAuth(token, this.config.auth);
700
+ if (!authResult.valid) {
701
+ res.writeHead(401, { "Content-Type": "application/json" });
702
+ res.end(JSON.stringify({ error: "Authentication failed" }));
703
+ return;
704
+ }
705
+
706
+ const body = await this.parseBody(req);
707
+ if (!body) {
708
+ res.writeHead(400, { "Content-Type": "application/json" });
709
+ res.end(JSON.stringify({ error: "Invalid request body" }));
710
+ return;
711
+ }
712
+
713
+ const method = body.method as string | undefined;
714
+ const params = (body.params ?? {}) as Record<string, unknown>;
715
+
716
+ if (!method || typeof method !== "string") {
717
+ res.writeHead(400, { "Content-Type": "application/json" });
718
+ res.end(JSON.stringify({ error: "method is required" }));
719
+ return;
720
+ }
721
+
722
+ log.debug({ method, params }, "handleInvoke");
723
+
724
+ const requestId = `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
725
+ const startTime = Date.now();
726
+ const sessionKey = params.sessionId as string | undefined;
727
+
728
+ // Emit DevTools request event
729
+ if (devToolsEmitter.hasSubscribers()) {
730
+ devToolsEmitter.emitEvent({
731
+ type: "gateway_request",
732
+ executionId: this.config.id,
733
+ requestId,
734
+ method,
735
+ sessionKey,
736
+ params,
737
+ sequence: this.devToolsSequence++,
738
+ timestamp: startTime,
739
+ } as DTGatewayRequestEvent);
740
+ }
741
+
742
+ try {
743
+ const result = await this.invokeMethod(method, params, authResult.user);
744
+ log.debug({ method, result }, "handleInvoke: completed");
745
+
746
+ // Emit DevTools response event
747
+ if (devToolsEmitter.hasSubscribers()) {
748
+ devToolsEmitter.emitEvent({
749
+ type: "gateway_response",
750
+ executionId: this.config.id,
751
+ requestId,
752
+ method,
753
+ ok: true,
754
+ latencyMs: Date.now() - startTime,
755
+ sequence: this.devToolsSequence++,
756
+ timestamp: Date.now(),
757
+ } as DTGatewayResponseEvent);
758
+ }
759
+
760
+ res.writeHead(200, { "Content-Type": "application/json" });
761
+ res.end(JSON.stringify(result));
762
+ } catch (error) {
763
+ log.error({ method, error }, "handleInvoke: failed");
764
+ const errorMessage = error instanceof Error ? error.message : String(error);
765
+
766
+ // Emit DevTools response event for error
767
+ if (devToolsEmitter.hasSubscribers()) {
768
+ devToolsEmitter.emitEvent({
769
+ type: "gateway_response",
770
+ executionId: this.config.id,
771
+ requestId,
772
+ method,
773
+ ok: false,
774
+ error: { code: "INVOKE_ERROR", message: errorMessage },
775
+ latencyMs: Date.now() - startTime,
776
+ sequence: this.devToolsSequence++,
777
+ timestamp: Date.now(),
778
+ } as DTGatewayResponseEvent);
779
+ }
780
+
781
+ const statusCode = isGuardError(error) ? 403 : 400;
782
+ res.writeHead(statusCode, { "Content-Type": "application/json" });
783
+ res.end(JSON.stringify({ error: errorMessage }));
784
+ }
785
+ }
786
+
787
+ private async handleSubscribe(req: NodeRequest, res: NodeResponse): Promise<void> {
788
+ if (req.method !== "POST") {
789
+ res.writeHead(405, { "Content-Type": "application/json" });
790
+ res.end(JSON.stringify({ error: "Method not allowed" }));
791
+ return;
792
+ }
793
+
794
+ const token = extractToken(req);
795
+ const authResult = await validateAuth(token, this.config.auth);
796
+ if (!authResult.valid) {
797
+ res.writeHead(401, { "Content-Type": "application/json" });
798
+ res.end(JSON.stringify({ error: "Authentication failed" }));
799
+ return;
800
+ }
801
+
802
+ const body = await this.parseBody(req);
803
+ if (!body) {
804
+ res.writeHead(400, { "Content-Type": "application/json" });
805
+ res.end(JSON.stringify({ error: "Invalid request body" }));
806
+ return;
807
+ }
808
+
809
+ // Support both formats:
810
+ // - { sessionId, clientId } - simple format
811
+ // - { connectionId, add: [...], remove: [...] } - client format
812
+ const clientId = (body.clientId ?? body.connectionId) as string | undefined;
813
+
814
+ if (!clientId) {
815
+ res.writeHead(400, { "Content-Type": "application/json" });
816
+ res.end(JSON.stringify({ error: "clientId or connectionId is required" }));
817
+ return;
818
+ }
819
+
820
+ // Handle additions
821
+ const addSessionIds: string[] = [];
822
+ if (body.sessionId) {
823
+ addSessionIds.push(body.sessionId as string);
824
+ }
825
+ if (Array.isArray(body.add)) {
826
+ addSessionIds.push(...(body.add as string[]));
827
+ }
828
+
829
+ // Handle removals
830
+ const removeSessionIds: string[] = [];
831
+ if (Array.isArray(body.remove)) {
832
+ removeSessionIds.push(...(body.remove as string[]));
833
+ }
834
+
835
+ if (addSessionIds.length === 0 && removeSessionIds.length === 0) {
836
+ res.writeHead(400, { "Content-Type": "application/json" });
837
+ res.end(JSON.stringify({ error: "sessionId, add[], or remove[] is required" }));
838
+ return;
839
+ }
840
+
841
+ // Process subscriptions
842
+ for (const sessionId of addSessionIds) {
843
+ await this.sessions.subscribe(sessionId, clientId);
844
+ }
845
+ for (const sessionId of removeSessionIds) {
846
+ this.sessions.unsubscribe(sessionId, clientId);
847
+ }
848
+
849
+ res.writeHead(200, { "Content-Type": "application/json" });
850
+ res.end(JSON.stringify({ ok: true }));
851
+ }
852
+
853
+ private async handleAbort(req: NodeRequest, res: NodeResponse): Promise<void> {
854
+ if (req.method !== "POST") {
855
+ res.writeHead(405, { "Content-Type": "application/json" });
856
+ res.end(JSON.stringify({ error: "Method not allowed" }));
857
+ return;
858
+ }
859
+
860
+ const token = extractToken(req);
861
+ const authResult = await validateAuth(token, this.config.auth);
862
+ if (!authResult.valid) {
863
+ res.writeHead(401, { "Content-Type": "application/json" });
864
+ res.end(JSON.stringify({ error: "Authentication failed" }));
865
+ return;
866
+ }
867
+
868
+ const body = await this.parseBody(req);
869
+ if (!body) {
870
+ res.writeHead(400, { "Content-Type": "application/json" });
871
+ res.end(JSON.stringify({ error: "Invalid request body" }));
872
+ return;
873
+ }
874
+
875
+ res.writeHead(200, { "Content-Type": "application/json" });
876
+ res.end(JSON.stringify({ ok: true }));
877
+ }
878
+
879
+ private async handleCloseEndpoint(req: NodeRequest, res: NodeResponse): Promise<void> {
880
+ if (req.method !== "POST") {
881
+ res.writeHead(405, { "Content-Type": "application/json" });
882
+ res.end(JSON.stringify({ error: "Method not allowed" }));
883
+ return;
884
+ }
885
+
886
+ const token = extractToken(req);
887
+ const authResult = await validateAuth(token, this.config.auth);
888
+ if (!authResult.valid) {
889
+ res.writeHead(401, { "Content-Type": "application/json" });
890
+ res.end(JSON.stringify({ error: "Authentication failed" }));
891
+ return;
892
+ }
893
+
894
+ const body = await this.parseBody(req);
895
+ if (!body?.sessionId) {
896
+ res.writeHead(400, { "Content-Type": "application/json" });
897
+ res.end(JSON.stringify({ error: "sessionId is required" }));
898
+ return;
899
+ }
900
+
901
+ await this.sessions.close(body.sessionId as string);
902
+
903
+ res.writeHead(200, { "Content-Type": "application/json" });
904
+ res.end(JSON.stringify({ ok: true }));
905
+ }
906
+
907
+ /**
908
+ * Channel endpoint - handles channel pub/sub operations.
909
+ */
910
+ private async handleChannel(req: NodeRequest, res: NodeResponse): Promise<void> {
911
+ if (req.method !== "POST") {
912
+ res.writeHead(405, { "Content-Type": "application/json" });
913
+ res.end(JSON.stringify({ error: "Method not allowed" }));
914
+ return;
915
+ }
916
+
917
+ const token = extractToken(req);
918
+ const authResult = await validateAuth(token, this.config.auth);
919
+ if (!authResult.valid) {
920
+ res.writeHead(401, { "Content-Type": "application/json" });
921
+ res.end(JSON.stringify({ error: "Authentication failed" }));
922
+ return;
923
+ }
924
+
925
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
926
+ const prefix = this.config.httpPathPrefix ?? "";
927
+ const path = url.pathname.replace(prefix, "") || "/";
928
+
929
+ const body = await this.parseBody(req);
930
+ if (!body) {
931
+ res.writeHead(400, { "Content-Type": "application/json" });
932
+ res.end(JSON.stringify({ error: "Invalid request body" }));
933
+ return;
934
+ }
935
+
936
+ const sessionId = body.sessionId as string | undefined;
937
+ const channelName = body.channel as string | undefined;
938
+ const clientId = body.clientId as string | undefined;
939
+
940
+ if (!sessionId || !channelName) {
941
+ res.writeHead(400, { "Content-Type": "application/json" });
942
+ res.end(JSON.stringify({ error: "sessionId and channel are required" }));
943
+ return;
944
+ }
945
+
946
+ if (path === "/channel/subscribe" || path === "/channel") {
947
+ await this.subscribeToChannel(sessionId, channelName, clientId);
948
+ res.writeHead(200, { "Content-Type": "application/json" });
949
+ res.end(JSON.stringify({ ok: true }));
950
+ } else if (path === "/channel/publish") {
951
+ const payload = body.payload;
952
+ await this.publishToChannel(sessionId, channelName, payload);
953
+ res.writeHead(200, { "Content-Type": "application/json" });
954
+ res.end(JSON.stringify({ ok: true }));
955
+ } else {
956
+ res.writeHead(404, { "Content-Type": "application/json" });
957
+ res.end(JSON.stringify({ error: "Unknown channel operation" }));
958
+ }
959
+ }
960
+
961
+ /**
962
+ * Subscribe a client to a session's channel.
963
+ * Sets up forwarding from core session channel to SSE clients.
964
+ */
965
+ private async subscribeToChannel(
966
+ sessionId: string,
967
+ channelName: string,
968
+ clientId?: string,
969
+ ): Promise<void> {
970
+ const subscriptionKey = `${sessionId}:${channelName}`;
971
+
972
+ // Add client to subscription list (if provided)
973
+ if (clientId) {
974
+ let clientIds = this.channelSubscriptions.get(subscriptionKey);
975
+ if (!clientIds) {
976
+ clientIds = new Set();
977
+ this.channelSubscriptions.set(subscriptionKey, clientIds);
978
+ }
979
+ clientIds.add(clientId);
980
+ }
981
+
982
+ // If we already have a core channel subscription for this session:channel, we're done
983
+ if (this.coreChannelUnsubscribes.has(subscriptionKey)) {
984
+ return;
985
+ }
986
+
987
+ // Get or create the managed session and core session
988
+ const managedSession = await this.sessions.getOrCreate(sessionId);
989
+ if (!managedSession.coreSession) {
990
+ // Use sessionName (without app prefix) for App - Gateway handles routing
991
+ managedSession.coreSession = await managedSession.appInfo.app.session(
992
+ managedSession.sessionName,
993
+ );
994
+ }
995
+
996
+ // Subscribe to the core session's channel and forward events to SSE clients
997
+ const coreChannel = managedSession.coreSession.channel(channelName);
998
+ const unsubscribe = coreChannel.subscribe((event) => {
999
+ this.forwardChannelEvent(subscriptionKey, event);
1000
+ });
1001
+
1002
+ this.coreChannelUnsubscribes.set(subscriptionKey, unsubscribe);
1003
+ log.debug({ sessionId, channelName }, "Channel forwarding established");
1004
+ }
1005
+
1006
+ /**
1007
+ * Forward a channel event to all subscribed SSE clients.
1008
+ */
1009
+ private forwardChannelEvent(
1010
+ subscriptionKey: string,
1011
+ event: { type: string; channel: string; payload: unknown; metadata?: unknown },
1012
+ ): void {
1013
+ const clientIds = this.channelSubscriptions.get(subscriptionKey);
1014
+ if (!clientIds || clientIds.size === 0) {
1015
+ return;
1016
+ }
1017
+
1018
+ // subscriptionKey format is "sessionId:channelName" where sessionId can contain ":"
1019
+ // e.g., "assistant:default:todo-list" → sessionId="assistant:default", channel="todo-list"
1020
+ // Extract sessionId by removing the last segment (channelName)
1021
+ const lastColonIndex = subscriptionKey.lastIndexOf(":");
1022
+ const sessionId = subscriptionKey.substring(0, lastColonIndex);
1023
+
1024
+ const sseData = JSON.stringify({
1025
+ type: "channel",
1026
+ sessionId,
1027
+ channel: event.channel,
1028
+ event: {
1029
+ type: event.type,
1030
+ payload: event.payload,
1031
+ metadata: event.metadata,
1032
+ },
1033
+ });
1034
+
1035
+ for (const clientId of clientIds) {
1036
+ const res = this.sseClients.get(clientId);
1037
+ if (res && !res.writableEnded) {
1038
+ res.write(`data: ${sseData}\n\n`);
1039
+ }
1040
+ }
1041
+ }
1042
+
1043
+ /**
1044
+ * Publish an event to a session's channel.
1045
+ */
1046
+ private async publishToChannel(
1047
+ sessionId: string,
1048
+ channelName: string,
1049
+ payload: unknown,
1050
+ ): Promise<void> {
1051
+ const managedSession = await this.sessions.getOrCreate(sessionId);
1052
+ if (!managedSession.coreSession) {
1053
+ // Use sessionName (without app prefix) for App - Gateway handles routing
1054
+ managedSession.coreSession = await managedSession.appInfo.app.session(
1055
+ managedSession.sessionName,
1056
+ );
1057
+ }
1058
+
1059
+ const coreChannel = managedSession.coreSession.channel(channelName);
1060
+ coreChannel.publish({
1061
+ type: "message",
1062
+ channel: channelName,
1063
+ payload,
1064
+ });
1065
+ }
1066
+
1067
+ private parseBody(
1068
+ req: NodeRequest & { body?: unknown },
1069
+ ): Promise<Record<string, unknown> | null> {
1070
+ // If body already parsed by Express middleware, use it
1071
+ if (req.body && typeof req.body === "object") {
1072
+ return Promise.resolve(req.body as Record<string, unknown>);
1073
+ }
1074
+
1075
+ // Otherwise, read from stream
1076
+ return new Promise((resolve) => {
1077
+ let body = "";
1078
+ req.on("data", (chunk) => {
1079
+ body += chunk.toString();
1080
+ });
1081
+ req.on("end", () => {
1082
+ try {
1083
+ resolve(JSON.parse(body));
1084
+ } catch {
1085
+ resolve(null);
1086
+ }
1087
+ });
1088
+ req.on("error", () => {
1089
+ resolve(null);
1090
+ });
1091
+ });
1092
+ }
1093
+
1094
+ // ══════════════════════════════════════════════════════════════════════════
1095
+ // Transport Request Handling (standalone mode)
1096
+ // ══════════════════════════════════════════════════════════════════════════
1097
+
1098
+ private async handleTransportRequest(
1099
+ transport: Transport,
1100
+ clientId: string,
1101
+ request: RequestMessage,
1102
+ ): Promise<void> {
1103
+ const client = transport.getClient(clientId);
1104
+ if (!client) return;
1105
+
1106
+ const startTime = Date.now();
1107
+ const requestId = request.id;
1108
+ const sessionKey = (request.params as Record<string, unknown>)?.sessionId as string | undefined;
1109
+
1110
+ // Emit DevTools request event
1111
+ if (devToolsEmitter.hasSubscribers()) {
1112
+ devToolsEmitter.emitEvent({
1113
+ type: "gateway_request",
1114
+ executionId: this.config.id,
1115
+ requestId,
1116
+ method: request.method,
1117
+ sessionKey,
1118
+ params: request.params as Record<string, unknown>,
1119
+ clientId,
1120
+ sequence: this.devToolsSequence++,
1121
+ timestamp: startTime,
1122
+ } as DTGatewayRequestEvent);
1123
+ }
1124
+
1125
+ try {
1126
+ const result = await this.executeMethod(transport, clientId, request.method, request.params);
1127
+
1128
+ client.send({
1129
+ type: "res",
1130
+ id: request.id,
1131
+ ok: true,
1132
+ payload: result,
1133
+ });
1134
+
1135
+ // Emit DevTools response event
1136
+ if (devToolsEmitter.hasSubscribers()) {
1137
+ devToolsEmitter.emitEvent({
1138
+ type: "gateway_response",
1139
+ executionId: this.config.id,
1140
+ requestId,
1141
+ ok: true,
1142
+ latencyMs: Date.now() - startTime,
1143
+ sequence: this.devToolsSequence++,
1144
+ timestamp: Date.now(),
1145
+ } as DTGatewayResponseEvent);
1146
+ }
1147
+ } catch (error) {
1148
+ const errorCode = "METHOD_ERROR";
1149
+ const errorMessage = error instanceof Error ? error.message : String(error);
1150
+
1151
+ client.send({
1152
+ type: "res",
1153
+ id: request.id,
1154
+ ok: false,
1155
+ error: {
1156
+ code: errorCode,
1157
+ message: errorMessage,
1158
+ },
1159
+ });
1160
+
1161
+ // Emit DevTools response event with error
1162
+ if (devToolsEmitter.hasSubscribers()) {
1163
+ devToolsEmitter.emitEvent({
1164
+ type: "gateway_response",
1165
+ executionId: this.config.id,
1166
+ requestId,
1167
+ ok: false,
1168
+ latencyMs: Date.now() - startTime,
1169
+ error: {
1170
+ code: errorCode,
1171
+ message: errorMessage,
1172
+ },
1173
+ sequence: this.devToolsSequence++,
1174
+ timestamp: Date.now(),
1175
+ } as DTGatewayResponseEvent);
1176
+ }
1177
+ }
1178
+ }
1179
+
1180
+ private async executeMethod(
1181
+ transport: Transport,
1182
+ clientId: string,
1183
+ method: GatewayMethod,
1184
+ params: Record<string, unknown>,
1185
+ ): Promise<unknown> {
1186
+ // Built-in methods first
1187
+ switch (method) {
1188
+ case "send":
1189
+ return this.handleSendMethod(transport, clientId, params as unknown as SendParams);
1190
+
1191
+ case "abort":
1192
+ return this.handleAbortMethod(params as unknown as { sessionId: string });
1193
+
1194
+ case "status":
1195
+ return this.handleStatusMethod(params as unknown as StatusParams);
1196
+
1197
+ case "history":
1198
+ return this.handleHistoryMethod(params as unknown as HistoryParams);
1199
+
1200
+ case "reset":
1201
+ return this.handleResetMethod(params as unknown as { sessionId: string });
1202
+
1203
+ case "close":
1204
+ return this.handleCloseMethod(params as unknown as { sessionId: string });
1205
+
1206
+ case "apps":
1207
+ return this.handleAppsMethod();
1208
+
1209
+ case "sessions":
1210
+ return this.handleSessionsMethod();
1211
+
1212
+ case "subscribe":
1213
+ return this.handleSubscribeMethod(
1214
+ transport,
1215
+ clientId,
1216
+ params as unknown as SubscribeParams,
1217
+ );
1218
+
1219
+ case "unsubscribe":
1220
+ return this.handleUnsubscribeMethod(
1221
+ transport,
1222
+ clientId,
1223
+ params as unknown as SubscribeParams,
1224
+ );
1225
+ }
1226
+
1227
+ // Check custom methods
1228
+ const procedure = this.getMethodProcedure(method);
1229
+ if (procedure) {
1230
+ return this.executeCustomMethod(transport, clientId, method, params);
1231
+ }
1232
+
1233
+ throw new Error(`Unknown method: ${method}`);
1234
+ }
1235
+
1236
+ /**
1237
+ * Execute a custom method within Agentick ALS context.
1238
+ */
1239
+ private async executeCustomMethod(
1240
+ transport: Transport,
1241
+ clientId: string,
1242
+ method: string,
1243
+ params: Record<string, unknown>,
1244
+ ): Promise<unknown> {
1245
+ const client = transport.getClient(clientId);
1246
+ const sessionId = params.sessionId as string | undefined;
1247
+
1248
+ // Build metadata: gateway fields + client auth metadata + per-request metadata
1249
+ const metadata = {
1250
+ sessionId,
1251
+ clientId,
1252
+ gatewayId: this.config.id,
1253
+ method,
1254
+ ...client?.state.metadata,
1255
+ ...(params.metadata as Record<string, unknown> | undefined),
1256
+ };
1257
+
1258
+ // Create kernel context
1259
+ const ctx = Context.create({
1260
+ user: client?.state.user,
1261
+ metadata,
1262
+ });
1263
+
1264
+ // Get the procedure
1265
+ const procedure = this.getMethodProcedure(method);
1266
+ if (!procedure) {
1267
+ throw new Error(`Unknown method: ${method}`);
1268
+ }
1269
+
1270
+ // Execute within context
1271
+ // Procedure handles: context forking, middleware (guards), schema validation, metrics
1272
+ const result = await Context.run(ctx, async () => {
1273
+ const handle = await procedure(params);
1274
+ return handle.result;
1275
+ });
1276
+
1277
+ // Handle streaming results
1278
+ if (result && typeof result === "object" && Symbol.asyncIterator in result) {
1279
+ const generator = result as AsyncGenerator<unknown>;
1280
+ const chunks: unknown[] = [];
1281
+
1282
+ for await (const chunk of generator) {
1283
+ // Emit chunk to subscribers
1284
+ if (sessionId) {
1285
+ this.sendEventToSubscribers(sessionId, "method:chunk", {
1286
+ method,
1287
+ chunk,
1288
+ });
1289
+ }
1290
+ chunks.push(chunk);
1291
+ }
1292
+
1293
+ // Emit end event
1294
+ if (sessionId) {
1295
+ this.sendEventToSubscribers(sessionId, "method:end", { method });
1296
+ }
1297
+
1298
+ return { streaming: true, chunks };
1299
+ }
1300
+
1301
+ return result;
1302
+ }
1303
+
1304
+ private async handleSendMethod(
1305
+ transport: Transport,
1306
+ clientId: string,
1307
+ params: SendParams,
1308
+ ): Promise<{ messageId: string }> {
1309
+ const { sessionId, message } = params;
1310
+
1311
+ // Get or create managed session (SessionManager emits DevTools event if new)
1312
+ const managedSession = await this.sessions.getOrCreate(sessionId, clientId);
1313
+
1314
+ // Auto-subscribe sender to session events
1315
+ // Subscribe with the ORIGINAL sessionId so events can be matched by clients
1316
+ const client = transport.getClient(clientId);
1317
+ if (client) {
1318
+ client.state.subscriptions.add(sessionId);
1319
+ await this.sessions.subscribe(sessionId, clientId);
1320
+ }
1321
+
1322
+ // Mark session as active (internal, uses normalized ID)
1323
+ this.sessions.setActive(managedSession.state.id, true);
1324
+
1325
+ // Get or create core session from app
1326
+ if (!managedSession.coreSession) {
1327
+ // Use sessionName (without app prefix) for App - Gateway handles routing
1328
+ managedSession.coreSession = await managedSession.appInfo.app.session(
1329
+ managedSession.sessionName,
1330
+ );
1331
+ }
1332
+
1333
+ // Stream execution to subscribers
1334
+ const messageId = `msg-${Date.now().toString(36)}`;
1335
+
1336
+ // Execute in background and stream events
1337
+ // Use ORIGINAL sessionId for events so clients can match them
1338
+ this.executeAndStream(sessionId, managedSession.coreSession, message).catch((error) => {
1339
+ this.sendEventToSubscribers(sessionId, "error", {
1340
+ message: error instanceof Error ? error.message : String(error),
1341
+ });
1342
+ });
1343
+
1344
+ // Increment message count (SessionManager emits DevTools event)
1345
+ this.sessions.incrementMessageCount(managedSession.state.id, clientId);
1346
+
1347
+ this.emit("session:message", {
1348
+ sessionId: managedSession.state.id,
1349
+ role: "user",
1350
+ content: message,
1351
+ });
1352
+
1353
+ return { messageId };
1354
+ }
1355
+
1356
+ /**
1357
+ * Execute a message and stream events to subscribers.
1358
+ *
1359
+ * @param sessionId - The session key as provided by client (may be unnormalized)
1360
+ * @param coreSession - The core session instance
1361
+ * @param messageText - The message text to send
1362
+ *
1363
+ * IMPORTANT: Uses the original sessionId for events to ensure client matching.
1364
+ */
1365
+ private async executeAndStream(
1366
+ sessionId: string,
1367
+ coreSession: Session,
1368
+ messageText: string,
1369
+ ): Promise<void> {
1370
+ try {
1371
+ // Construct a proper Message object from the string
1372
+ const message = {
1373
+ role: "user" as const,
1374
+ content: [{ type: "text" as const, text: messageText }],
1375
+ };
1376
+
1377
+ const execution = await coreSession.send({ messages: [message] });
1378
+
1379
+ for await (const event of execution) {
1380
+ // Use the original sessionId for events (ensures client matching)
1381
+ this.sendEventToSubscribers(sessionId, event.type, event);
1382
+ }
1383
+
1384
+ // Send execution_end event
1385
+ this.sendEventToSubscribers(sessionId, "execution_end", {});
1386
+ } finally {
1387
+ this.sessions.setActive(sessionId, false);
1388
+ }
1389
+ }
1390
+
1391
+ private sendEventToSubscribers(sessionId: string, eventType: string, data: unknown): void {
1392
+ const subscribers = this.sessions.getSubscribers(sessionId);
1393
+
1394
+ // Send to all clients across all transports (standalone mode)
1395
+ for (const transport of this.transports) {
1396
+ for (const clientId of subscribers) {
1397
+ const client = transport.getClient(clientId);
1398
+ if (client) {
1399
+ client.send({
1400
+ type: "event",
1401
+ event: eventType as GatewayEventType,
1402
+ sessionId,
1403
+ data,
1404
+ });
1405
+ }
1406
+ }
1407
+ }
1408
+
1409
+ // Also send to SSE clients (embedded mode)
1410
+ for (const clientId of subscribers) {
1411
+ const res = this.sseClients.get(clientId);
1412
+ if (res && !res.writableEnded) {
1413
+ const sseData = JSON.stringify({
1414
+ type: eventType,
1415
+ sessionId,
1416
+ ...(data && typeof data === "object" ? data : {}),
1417
+ });
1418
+ res.write(`data: ${sseData}\n\n`);
1419
+ }
1420
+ }
1421
+ }
1422
+
1423
+ /**
1424
+ * Direct send handler for HTTP transport.
1425
+ * Returns an async generator that yields events for streaming.
1426
+ * Accepts full Message object to support multimodal content (images, audio, video, docs).
1427
+ *
1428
+ * IMPORTANT: Uses the original sessionId (as provided by client) for events,
1429
+ * not the normalized internal ID. This ensures clients can match events to their sessions.
1430
+ */
1431
+ private async *directSend(
1432
+ sessionId: string,
1433
+ message: Message,
1434
+ ): AsyncGenerator<{ type: string; data?: unknown }> {
1435
+ // Get or create managed session
1436
+ const managedSession = await this.sessions.getOrCreate(sessionId);
1437
+
1438
+ log.debug(
1439
+ {
1440
+ sessionId,
1441
+ sessionName: managedSession.sessionName,
1442
+ stateId: managedSession.state.id,
1443
+ hasCoreSession: !!managedSession.coreSession,
1444
+ },
1445
+ "directSend: got managed session",
1446
+ );
1447
+
1448
+ // Mark session as active
1449
+ this.sessions.setActive(managedSession.state.id, true);
1450
+
1451
+ // Get or create core session from app
1452
+ if (!managedSession.coreSession) {
1453
+ // Use sessionName (without app prefix) for App - Gateway handles routing
1454
+ log.debug({ sessionName: managedSession.sessionName }, "directSend: creating core session");
1455
+ managedSession.coreSession = await managedSession.appInfo.app.session(
1456
+ managedSession.sessionName,
1457
+ );
1458
+ log.debug(
1459
+ { coreSessionId: managedSession.coreSession.id },
1460
+ "directSend: created core session",
1461
+ );
1462
+ }
1463
+
1464
+ // Check session status before sending
1465
+ const coreSession = managedSession.coreSession as any;
1466
+ log.debug(
1467
+ {
1468
+ coreSessionId: coreSession.id,
1469
+ status: coreSession._status,
1470
+ },
1471
+ "directSend: core session status before send",
1472
+ );
1473
+
1474
+ try {
1475
+ const execution = await managedSession.coreSession.send({ messages: [message] });
1476
+
1477
+ // Increment message count
1478
+ this.sessions.incrementMessageCount(managedSession.state.id);
1479
+
1480
+ // Extract text content for logging (first text block if any)
1481
+ const textContent = message.content
1482
+ .filter((b): b is { type: "text"; text: string } => b.type === "text")
1483
+ .map((b) => b.text)
1484
+ .join(" ");
1485
+
1486
+ this.emit("session:message", {
1487
+ sessionId: managedSession.state.id,
1488
+ role: "user",
1489
+ content: textContent || "[multimodal content]",
1490
+ });
1491
+
1492
+ for await (const event of execution) {
1493
+ // Use the ORIGINAL sessionId for events (not normalized managedSession.state.id)
1494
+ // This ensures clients can match events to sessions by the key they used
1495
+ this.sendEventToSubscribers(sessionId, event.type, event);
1496
+
1497
+ // Yield event for HTTP streaming
1498
+ yield { type: event.type, data: event };
1499
+ }
1500
+ } finally {
1501
+ this.sessions.setActive(managedSession.state.id, false);
1502
+ }
1503
+ }
1504
+
1505
+ /**
1506
+ * Invoke a custom method directly (for HTTP transport).
1507
+ * Called with pre-authenticated user context.
1508
+ */
1509
+ private async invokeMethod(
1510
+ method: string,
1511
+ params: Record<string, unknown>,
1512
+ user?: UserContext,
1513
+ ): Promise<unknown> {
1514
+ const procedure = this.getMethodProcedure(method);
1515
+ if (!procedure) {
1516
+ throw new Error(`Unknown method: ${method}`);
1517
+ }
1518
+
1519
+ const sessionId = params.sessionId as string | undefined;
1520
+
1521
+ // Get or create session to access channels (if sessionId provided)
1522
+ let channels: ChannelServiceInterface | undefined = undefined;
1523
+ if (sessionId) {
1524
+ const managedSession = await this.sessions.getOrCreate(sessionId);
1525
+ if (!managedSession.coreSession) {
1526
+ // Use sessionName (without app prefix) for App - Gateway handles routing
1527
+ managedSession.coreSession = await managedSession.appInfo.app.session(
1528
+ managedSession.sessionName,
1529
+ );
1530
+ }
1531
+ channels = createChannelServiceFromSession(managedSession.coreSession, this.config.id);
1532
+ }
1533
+
1534
+ // Build metadata
1535
+ const metadata = {
1536
+ sessionId,
1537
+ gatewayId: this.config.id,
1538
+ method,
1539
+ ...(params.metadata as Record<string, unknown> | undefined),
1540
+ };
1541
+
1542
+ // Create kernel context with channels for pub/sub
1543
+ const ctx = Context.create({
1544
+ user,
1545
+ metadata,
1546
+ channels,
1547
+ });
1548
+
1549
+ // Execute within context
1550
+ // Procedures return ExecutionHandle by default - access .result to get the handler's return value
1551
+ const result = await Context.run(ctx, async () => {
1552
+ const handle = await procedure(params);
1553
+ return handle.result;
1554
+ });
1555
+
1556
+ // Handle streaming results (collect all chunks)
1557
+ if (result && typeof result === "object" && Symbol.asyncIterator in result) {
1558
+ const iterable = result as AsyncIterable<unknown>;
1559
+ const chunks: unknown[] = [];
1560
+
1561
+ for await (const chunk of iterable) {
1562
+ chunks.push(chunk);
1563
+ }
1564
+
1565
+ return { streaming: true, chunks };
1566
+ }
1567
+
1568
+ return result;
1569
+ }
1570
+
1571
+ private async handleAbortMethod(params: { sessionId: string }): Promise<void> {
1572
+ const session = this.sessions.get(params.sessionId);
1573
+ if (!session) {
1574
+ throw new Error(`Session not found: ${params.sessionId}`);
1575
+ }
1576
+ }
1577
+
1578
+ private handleStatusMethod(params: StatusParams): StatusPayload {
1579
+ const result: StatusPayload = {
1580
+ gateway: this.status,
1581
+ };
1582
+
1583
+ if (params.sessionId) {
1584
+ const session = this.sessions.get(params.sessionId);
1585
+ if (session) {
1586
+ result.session = {
1587
+ id: session.state.id,
1588
+ appId: session.state.appId,
1589
+ messageCount: session.state.messageCount,
1590
+ createdAt: session.state.createdAt.toISOString(),
1591
+ lastActivityAt: session.state.lastActivityAt.toISOString(),
1592
+ isActive: session.state.isActive,
1593
+ };
1594
+ }
1595
+ }
1596
+
1597
+ return result;
1598
+ }
1599
+
1600
+ private async handleHistoryMethod(
1601
+ params: HistoryParams,
1602
+ ): Promise<{ messages: unknown[]; hasMore: boolean }> {
1603
+ return { messages: [], hasMore: false };
1604
+ }
1605
+
1606
+ private async handleResetMethod(params: { sessionId: string }): Promise<void> {
1607
+ // SessionManager.reset() emits DevTools event
1608
+ await this.sessions.reset(params.sessionId);
1609
+ this.emit("session:closed", { sessionId: params.sessionId });
1610
+ }
1611
+
1612
+ private async handleCloseMethod(params: { sessionId: string }): Promise<void> {
1613
+ // SessionManager.close() emits DevTools event
1614
+ await this.sessions.close(params.sessionId);
1615
+ this.emit("session:closed", { sessionId: params.sessionId });
1616
+ }
1617
+
1618
+ private handleAppsMethod(): AppsPayload {
1619
+ return {
1620
+ apps: this.registry.all().map((appInfo) => ({
1621
+ id: appInfo.id,
1622
+ name: appInfo.name ?? appInfo.id,
1623
+ description: appInfo.description,
1624
+ isDefault: appInfo.isDefault,
1625
+ })),
1626
+ };
1627
+ }
1628
+
1629
+ private handleSessionsMethod(): SessionsPayload {
1630
+ return {
1631
+ sessions: this.sessions.all().map((s) => ({
1632
+ id: s.state.id,
1633
+ appId: s.state.appId,
1634
+ createdAt: s.state.createdAt.toISOString(),
1635
+ lastActivityAt: s.state.lastActivityAt.toISOString(),
1636
+ messageCount: s.state.messageCount,
1637
+ })),
1638
+ };
1639
+ }
1640
+
1641
+ private async handleSubscribeMethod(
1642
+ transport: Transport,
1643
+ clientId: string,
1644
+ params: SubscribeParams,
1645
+ ): Promise<void> {
1646
+ const client = transport.getClient(clientId);
1647
+ if (client) {
1648
+ client.state.subscriptions.add(params.sessionId);
1649
+ await this.sessions.subscribe(params.sessionId, clientId);
1650
+ }
1651
+ }
1652
+
1653
+ private handleUnsubscribeMethod(
1654
+ transport: Transport,
1655
+ clientId: string,
1656
+ params: SubscribeParams,
1657
+ ): void {
1658
+ const client = transport.getClient(clientId);
1659
+ if (client) {
1660
+ client.state.subscriptions.delete(params.sessionId);
1661
+ this.sessions.unsubscribe(params.sessionId, clientId);
1662
+ }
1663
+ }
1664
+
1665
+ private createGatewayContext(): GatewayContext {
1666
+ return {
1667
+ sendToSession: async (sessionId, message) => {
1668
+ // Internal send (from channels)
1669
+ const managedSession = await this.sessions.getOrCreate(sessionId);
1670
+
1671
+ if (!managedSession.coreSession) {
1672
+ // Use sessionName (without app prefix) for App - Gateway handles routing
1673
+ managedSession.coreSession = await managedSession.appInfo.app.session(
1674
+ managedSession.sessionName,
1675
+ );
1676
+ }
1677
+
1678
+ await this.executeAndStream(managedSession.state.id, managedSession.coreSession, message);
1679
+ },
1680
+
1681
+ getApps: () => this.registry.ids(),
1682
+
1683
+ getSession: (sessionId) => {
1684
+ const managedSession = this.sessions.get(sessionId);
1685
+ if (!managedSession) {
1686
+ throw new Error(`Session not found: ${sessionId}`);
1687
+ }
1688
+
1689
+ return {
1690
+ id: managedSession.state.id,
1691
+ appId: managedSession.state.appId,
1692
+ send: async function* (message: string): AsyncGenerator<SessionEvent> {
1693
+ yield { type: "message_end", data: {} };
1694
+ },
1695
+ };
1696
+ },
1697
+ };
1698
+ }
1699
+ }
1700
+
1701
+ // Type declaration for EventEmitter
1702
+ export interface Gateway {
1703
+ on<K extends keyof GatewayEvents>(event: K, listener: (payload: GatewayEvents[K]) => void): this;
1704
+ emit<K extends keyof GatewayEvents>(event: K, payload: GatewayEvents[K]): boolean;
1705
+ }
1706
+
1707
+ /**
1708
+ * Create a gateway instance
1709
+ */
1710
+ export function createGateway(config: GatewayConfig): Gateway {
1711
+ return new Gateway(config);
1712
+ }