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