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