@agentick/gateway 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +477 -0
- package/dist/agent-registry.d.ts +51 -0
- package/dist/agent-registry.d.ts.map +1 -0
- package/dist/agent-registry.js +78 -0
- package/dist/agent-registry.js.map +1 -0
- package/dist/app-registry.d.ts +51 -0
- package/dist/app-registry.d.ts.map +1 -0
- package/dist/app-registry.js +78 -0
- package/dist/app-registry.js.map +1 -0
- package/dist/bin.d.ts +8 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +37 -0
- package/dist/bin.js.map +1 -0
- package/dist/gateway.d.ts +165 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +1339 -0
- package/dist/gateway.js.map +1 -0
- package/dist/http-transport.d.ts +65 -0
- package/dist/http-transport.d.ts.map +1 -0
- package/dist/http-transport.js +517 -0
- package/dist/http-transport.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +162 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +16 -0
- package/dist/protocol.js.map +1 -0
- package/dist/session-manager.d.ts +101 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +208 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/testing.d.ts +92 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +129 -0
- package/dist/testing.js.map +1 -0
- package/dist/transport-protocol.d.ts +162 -0
- package/dist/transport-protocol.d.ts.map +1 -0
- package/dist/transport-protocol.js +16 -0
- package/dist/transport-protocol.js.map +1 -0
- package/dist/transport.d.ts +115 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +56 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +314 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +37 -0
- package/dist/types.js.map +1 -0
- package/dist/websocket-server.d.ts +87 -0
- package/dist/websocket-server.d.ts.map +1 -0
- package/dist/websocket-server.js +245 -0
- package/dist/websocket-server.js.map +1 -0
- package/dist/ws-transport.d.ts +17 -0
- package/dist/ws-transport.d.ts.map +1 -0
- package/dist/ws-transport.js +174 -0
- package/dist/ws-transport.js.map +1 -0
- package/package.json +51 -0
- package/src/__tests__/custom-methods.spec.ts +220 -0
- package/src/__tests__/gateway-methods.spec.ts +262 -0
- package/src/__tests__/gateway.spec.ts +404 -0
- package/src/__tests__/guards.spec.ts +235 -0
- package/src/__tests__/protocol.spec.ts +58 -0
- package/src/__tests__/session-manager.spec.ts +220 -0
- package/src/__tests__/ws-transport.spec.ts +246 -0
- package/src/app-registry.ts +103 -0
- package/src/bin.ts +38 -0
- package/src/gateway.ts +1712 -0
- package/src/http-transport.ts +623 -0
- package/src/index.ts +94 -0
- package/src/session-manager.ts +272 -0
- package/src/testing.ts +236 -0
- package/src/transport-protocol.ts +249 -0
- package/src/transport.ts +191 -0
- package/src/types.ts +392 -0
- package/src/websocket-server.ts +303 -0
- package/src/ws-transport.ts +205 -0
package/dist/gateway.js
ADDED
|
@@ -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
|