@linkshell/gateway 0.3.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/Dockerfile +1 -3
  2. package/README.md +13 -14
  3. package/dist/gateway/src/agent-permission-http.d.ts +74 -19
  4. package/dist/gateway/src/agent-permission-http.js +56 -16
  5. package/dist/gateway/src/agent-permission-http.js.map +1 -1
  6. package/dist/gateway/src/embedded.js +61 -153
  7. package/dist/gateway/src/embedded.js.map +1 -1
  8. package/dist/gateway/src/index.js +98 -193
  9. package/dist/gateway/src/index.js.map +1 -1
  10. package/dist/gateway/src/pairings.d.ts +3 -3
  11. package/dist/gateway/src/pairings.js +5 -4
  12. package/dist/gateway/src/pairings.js.map +1 -1
  13. package/dist/gateway/src/relay.d.ts +2 -2
  14. package/dist/gateway/src/relay.js +85 -161
  15. package/dist/gateway/src/relay.js.map +1 -1
  16. package/dist/gateway/src/sessions.d.ts +28 -42
  17. package/dist/gateway/src/sessions.js +145 -200
  18. package/dist/gateway/src/sessions.js.map +1 -1
  19. package/dist/gateway/src/state-store.d.ts +6 -9
  20. package/dist/gateway/src/state-store.js +19 -26
  21. package/dist/gateway/src/state-store.js.map +1 -1
  22. package/dist/gateway/src/tokens.d.ts +7 -27
  23. package/dist/gateway/src/tokens.js +60 -86
  24. package/dist/gateway/src/tokens.js.map +1 -1
  25. package/dist/gateway/src/tunnel.d.ts +13 -11
  26. package/dist/gateway/src/tunnel.js +36 -36
  27. package/dist/gateway/src/tunnel.js.map +1 -1
  28. package/dist/gateway/tsconfig.tsbuildinfo +1 -1
  29. package/dist/shared-protocol/src/index.d.ts +11978 -3423
  30. package/dist/shared-protocol/src/index.js +114 -163
  31. package/dist/shared-protocol/src/index.js.map +1 -1
  32. package/package.json +11 -11
  33. package/src/agent-permission-http.ts +63 -20
  34. package/src/embedded.ts +60 -158
  35. package/src/index.ts +98 -199
  36. package/src/pairings.ts +7 -6
  37. package/src/relay.ts +97 -193
  38. package/src/sessions.ts +150 -213
  39. package/src/state-store.ts +25 -41
  40. package/src/tokens.ts +63 -109
  41. package/src/tunnel.ts +43 -49
package/src/embedded.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { createServer } from "node:http";
2
2
  import type { IncomingMessage, ServerResponse, Server } from "node:http";
3
3
  import { randomUUID } from "node:crypto";
4
- import { monitorEventLoopDelay } from "node:perf_hooks";
5
4
  import { WebSocketServer } from "ws";
6
5
  import type WebSocket from "ws";
7
6
  import {
@@ -10,7 +9,7 @@ import {
10
9
  PROTOCOL_VERSION,
11
10
  } from "@linkshell/protocol";
12
11
  import { z, ZodError } from "zod";
13
- import { DeviceManager } from "./sessions.js";
12
+ import { SessionManager } from "./sessions.js";
14
13
  import { PairingManager } from "./pairings.js";
15
14
  import { TokenManager } from "./tokens.js";
16
15
  import { handleSocketMessage } from "./relay.js";
@@ -41,18 +40,14 @@ export interface EmbeddedGateway {
41
40
 
42
41
  const PING_INTERVAL = 20_000;
43
42
  const MAX_BODY_SIZE = 4096;
44
- const MAX_WS_MESSAGE_SIZE = Number(
45
- process.env.MAX_WS_MESSAGE_SIZE ?? 16 * 1024 * 1024,
46
- );
43
+ const MAX_WS_MESSAGE_SIZE = 50 * 1024 * 1024; // 50MB (supports base64 image uploads)
47
44
 
48
45
  const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
49
46
 
50
- const createPairingBody = z.object({ hostDeviceId: z.string().min(1) });
47
+ const createPairingBody = z.object({ sessionId: z.string().optional() });
51
48
  const claimPairingBody = z.object({
52
49
  pairingCode: z.string().length(6),
53
50
  deviceToken: z.string().min(1).optional(),
54
- clientDeviceId: z.string().min(1).optional(),
55
- clientName: z.string().min(1).optional(),
56
51
  });
57
52
 
58
53
  class BodyTooLargeError extends Error {}
@@ -111,11 +106,9 @@ export function startEmbeddedGateway(
111
106
  }
112
107
  }
113
108
 
114
- const sessionManager = new DeviceManager();
109
+ const sessionManager = new SessionManager();
115
110
  const pairingManager = new PairingManager();
116
111
  const tokenManager = new TokenManager();
117
- const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
118
- eventLoopDelay.enable();
119
112
 
120
113
  const server = createServer(async (req, res) => {
121
114
  if (req.method === "OPTIONS") {
@@ -134,30 +127,7 @@ export function startEmbeddedGateway(
134
127
  const method = req.method ?? "GET";
135
128
 
136
129
  if (method === "GET" && url.pathname === "/healthz") {
137
- const memory = process.memoryUsage();
138
- const detailed = url.searchParams.get("detail") === "1" || url.searchParams.get("detailed") === "true";
139
- json(res, 200, {
140
- ok: true,
141
- uptime: Math.round(process.uptime()),
142
- memory: {
143
- rss: memory.rss,
144
- heapUsed: memory.heapUsed,
145
- heapTotal: memory.heapTotal,
146
- external: memory.external,
147
- },
148
- sessions: sessionManager.getStats(),
149
- ...(detailed ? {
150
- eventLoop: {
151
- delayMeanMs: Number((eventLoopDelay.mean / 1_000_000).toFixed(2)),
152
- delayMaxMs: Number((eventLoopDelay.max / 1_000_000).toFixed(2)),
153
- delayP99Ms: Number((eventLoopDelay.percentile(99) / 1_000_000).toFixed(2)),
154
- },
155
- websocket: {
156
- maxPayloadBytes: MAX_WS_MESSAGE_SIZE,
157
- perMessageDeflate: false,
158
- },
159
- } : {}),
160
- });
130
+ json(res, 200, { ok: true });
161
131
  return;
162
132
  }
163
133
 
@@ -167,7 +137,7 @@ export function startEmbeddedGateway(
167
137
  if (!parsed.success) {
168
138
  json(res, 400, {
169
139
  error: "invalid_payload",
170
- message: parsed.error.issues[0]?.message ?? "Invalid permission response payload",
140
+ message: parsed.error.errors[0]?.message ?? "Invalid permission response payload",
171
141
  });
172
142
  return;
173
143
  }
@@ -182,16 +152,16 @@ export function startEmbeddedGateway(
182
152
  item.terminalId ? `${item.type}:${item.terminalId}` : item.type,
183
153
  ).join(",") ?? "none";
184
154
  const ack = result.ack ? ` resolved=${result.ack.resolved} delivered=${result.ack.delivered}` : "";
185
- log(result.status === 200 ? "info" : "warn", `agent permission respond protocol=${body.protocol} hostDevice=${body.hostDeviceId} request=${body.requestId} status=${result.status} forwarded=${forwarded}${ack}`);
155
+ log(result.status === 200 ? "info" : "warn", `agent permission respond protocol=${body.protocol} session=${body.sessionId} request=${body.requestId} status=${result.status} forwarded=${forwarded}${ack}`);
186
156
  json(res, result.status, result.body);
187
157
  return;
188
158
  }
189
159
 
190
160
  if (method === "POST" && url.pathname === "/pairings") {
191
161
  const body = createPairingBody.parse(await readJson(req));
192
- const record = pairingManager.create(body.hostDeviceId);
162
+ const record = pairingManager.create(body.sessionId);
193
163
  json(res, 201, {
194
- hostDeviceId: record.hostDeviceId,
164
+ sessionId: record.sessionId,
195
165
  pairingCode: record.pairingCode,
196
166
  expiresAt: new Date(record.expiresAt).toISOString(),
197
167
  });
@@ -206,19 +176,12 @@ export function startEmbeddedGateway(
206
176
  return;
207
177
  }
208
178
  const token = tokenManager.register(body.deviceToken);
209
- const authorization = tokenManager.authorize(token, result.hostDeviceId, {
210
- clientDeviceId: body.clientDeviceId,
211
- clientName: body.clientName,
212
- });
213
- json(res, 200, {
214
- hostDeviceId: result.hostDeviceId,
215
- deviceToken: token,
216
- authorizationId: authorization?.authorizationId,
217
- });
179
+ tokenManager.bind(token, result.sessionId);
180
+ json(res, 200, { sessionId: result.sessionId, deviceToken: token });
218
181
  return;
219
182
  }
220
183
 
221
- if (method === "GET" && url.pathname === "/devices") {
184
+ if (method === "GET" && url.pathname === "/sessions") {
222
185
  const token = extractBearerToken(req);
223
186
  if (!token || !tokenManager.validate(token)) {
224
187
  json(res, 401, {
@@ -227,38 +190,33 @@ export function startEmbeddedGateway(
227
190
  });
228
191
  return;
229
192
  }
230
- const allowedIds = tokenManager.getHostDeviceIds(token);
231
- const devices = [...allowedIds].map((hostDeviceId) => {
232
- const summary = sessionManager.getSummary(hostDeviceId);
233
- return {
234
- ...(summary ?? {
235
- id: hostDeviceId,
236
- hostDeviceId,
237
- state: "host_disconnected",
238
- online: false,
239
- hasHost: false,
240
- clientCount: 0,
241
- controllerId: null,
242
- lastActivity: null,
243
- createdAt: null,
244
- bufferSize: 0,
245
- machineId: null,
246
- hostname: null,
247
- platform: null,
248
- cwd: null,
249
- capabilities: [],
250
- }),
251
- authorizationId: tokenManager.getAuthorizationId(token, hostDeviceId) ?? null,
252
- };
253
- });
254
- json(res, 200, { devices });
193
+ const allowedIds = tokenManager.getSessionIds(token);
194
+ const sessions = sessionManager
195
+ .listActive()
196
+ .filter((s) => allowedIds.has(s.id))
197
+ .map((s) => ({
198
+ id: s.id,
199
+ state: s.state,
200
+ hasHost: !!s.host && s.host.socket.readyState === s.host.socket.OPEN,
201
+ clientCount: s.clients.size,
202
+ controllerId: s.controllerId ?? null,
203
+ lastActivity: s.lastActivity,
204
+ createdAt: s.createdAt,
205
+ provider: s.provider ?? null,
206
+ machineId: s.machineId ?? null,
207
+ hostname: s.hostname ?? null,
208
+ platform: s.platform ?? null,
209
+ cwd: s.cwd ?? null,
210
+ projectName: s.projectName ?? null,
211
+ }));
212
+ json(res, 200, { sessions });
255
213
  return;
256
214
  }
257
215
 
258
- const deviceMatch = url.pathname.match(/^\/devices\/([^/]+)$/);
259
- if (method === "GET" && deviceMatch) {
216
+ const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
217
+ if (method === "GET" && sessionMatch) {
260
218
  const token = extractBearerToken(req);
261
- const targetId = deviceMatch[1]!;
219
+ const targetId = sessionMatch[1]!;
262
220
  if (!token || !tokenManager.owns(token, targetId)) {
263
221
  json(res, 401, {
264
222
  error: "unauthorized",
@@ -268,51 +226,10 @@ export function startEmbeddedGateway(
268
226
  }
269
227
  const summary = sessionManager.getSummary(targetId);
270
228
  if (!summary) {
271
- json(res, 200, {
272
- id: targetId,
273
- hostDeviceId: targetId,
274
- state: "host_disconnected",
275
- online: false,
276
- hasHost: false,
277
- clientCount: 0,
278
- controllerId: null,
279
- lastActivity: null,
280
- createdAt: null,
281
- bufferSize: 0,
282
- machineId: null,
283
- hostname: null,
284
- platform: null,
285
- cwd: null,
286
- capabilities: [],
287
- authorizationId: tokenManager.getAuthorizationId(token, targetId) ?? null,
288
- });
289
- return;
290
- }
291
- json(res, 200, {
292
- ...summary,
293
- authorizationId: tokenManager.getAuthorizationId(token, targetId) ?? null,
294
- });
295
- return;
296
- }
297
-
298
- const revokeMatch = url.pathname.match(/^\/devices\/([^/]+)\/authorizations\/([^/]+)$/);
299
- if (method === "DELETE" && revokeMatch) {
300
- const token = extractBearerToken(req);
301
- const hostDeviceId = decodeURIComponent(revokeMatch[1]!);
302
- const authorizationId = decodeURIComponent(revokeMatch[2]!);
303
- if (
304
- !token ||
305
- tokenManager.getAuthorizationId(token, hostDeviceId) !== authorizationId ||
306
- !tokenManager.revoke(token, hostDeviceId, authorizationId)
307
- ) {
308
- json(res, 401, {
309
- error: "unauthorized",
310
- message: "Valid device authorization required",
311
- });
229
+ json(res, 404, { error: "session_not_found" });
312
230
  return;
313
231
  }
314
- sessionManager.disconnectAuthorization(hostDeviceId, authorizationId);
315
- json(res, 200, { ok: true });
232
+ json(res, 200, summary);
316
233
  return;
317
234
  }
318
235
 
@@ -338,7 +255,7 @@ export function startEmbeddedGateway(
338
255
  const tunnelCookie = parseTunnelCookie(req);
339
256
  if (tunnelCookie) {
340
257
  const fallbackParsed = {
341
- hostDeviceId: tunnelCookie.hostDeviceId,
258
+ sessionId: tunnelCookie.sessionId,
342
259
  port: tunnelCookie.port,
343
260
  path: url.pathname,
344
261
  };
@@ -351,7 +268,7 @@ export function startEmbeddedGateway(
351
268
  if (err instanceof ZodError) {
352
269
  json(res, 400, {
353
270
  error: "invalid_message",
354
- message: err.issues[0]?.message ?? "Validation failed",
271
+ message: err.errors[0]?.message ?? "Validation failed",
355
272
  });
356
273
  } else if (err instanceof BodyTooLargeError) {
357
274
  json(res, 413, {
@@ -373,7 +290,6 @@ export function startEmbeddedGateway(
373
290
  const wss = new WebSocketServer({
374
291
  noServer: true,
375
292
  maxPayload: MAX_WS_MESSAGE_SIZE,
376
- perMessageDeflate: false,
377
293
  });
378
294
 
379
295
  server.on("upgrade", (request, socket, head) => {
@@ -392,7 +308,7 @@ export function startEmbeddedGateway(
392
308
  const tunnelCookie = parseTunnelCookie(request);
393
309
  if (tunnelCookie && url.pathname !== "/ws") {
394
310
  const fallbackParsed = {
395
- hostDeviceId: tunnelCookie.hostDeviceId,
311
+ sessionId: tunnelCookie.sessionId,
396
312
  port: tunnelCookie.port,
397
313
  path: url.pathname,
398
314
  };
@@ -415,50 +331,38 @@ export function startEmbeddedGateway(
415
331
  wss.on(
416
332
  "connection",
417
333
  (socket: WebSocket, _request: IncomingMessage, url: URL) => {
418
- const hostDeviceId = url.searchParams.get("hostDeviceId");
334
+ const sessionId = url.searchParams.get("sessionId");
419
335
  const role = url.searchParams.get("role") as "host" | "client" | null;
420
336
 
421
- if (!hostDeviceId || !role || (role !== "host" && role !== "client")) {
422
- socket.close(1008, "missing hostDeviceId or role");
337
+ if (!sessionId || !role || (role !== "host" && role !== "client")) {
338
+ socket.close(1008, "missing sessionId or role");
423
339
  return;
424
340
  }
425
341
 
426
342
  const deviceId = url.searchParams.get("deviceId") ?? randomUUID();
427
343
 
428
- let clientToken: string | undefined;
429
- let clientAuthorizationId: string | undefined;
430
-
431
344
  if (role === "client") {
432
345
  const token = url.searchParams.get("token");
433
- if (!token || !tokenManager.owns(token, hostDeviceId)) {
346
+ if (!token || !tokenManager.owns(token, sessionId)) {
434
347
  socket.close(4001, "unauthorized");
435
348
  return;
436
349
  }
437
- clientToken = token;
438
- clientAuthorizationId = tokenManager.getAuthorizationId(token, hostDeviceId);
439
350
  }
440
351
 
441
- const device = {
442
- socket,
443
- role,
444
- deviceId,
445
- token: clientToken,
446
- authorizationId: clientAuthorizationId,
447
- connectedAt: Date.now(),
448
- };
352
+ const device = { socket, role, deviceId, connectedAt: Date.now() };
449
353
 
450
354
  if (role === "host") {
451
- const existingSession = sessionManager.get(hostDeviceId);
355
+ const existingSession = sessionManager.get(sessionId);
452
356
  const isReconnect =
453
357
  existingSession &&
454
358
  existingSession.clients.size > 0 &&
455
359
  existingSession.state === "host_disconnected";
456
- sessionManager.setHost(hostDeviceId, device);
360
+ sessionManager.setHost(sessionId, device);
457
361
  if (isReconnect) {
458
362
  const notification = serializeEnvelope(
459
363
  createEnvelope({
460
- type: "device.host_reconnected",
461
- hostDeviceId,
364
+ type: "session.host_reconnected",
365
+ sessionId,
462
366
  payload: {},
463
367
  }),
464
368
  );
@@ -468,14 +372,14 @@ export function startEmbeddedGateway(
468
372
  }
469
373
  }
470
374
  } else {
471
- sessionManager.addClient(hostDeviceId, device);
375
+ sessionManager.addClient(sessionId, device);
472
376
  }
473
377
 
474
378
  socket.send(
475
379
  serializeEnvelope(
476
380
  createEnvelope({
477
- type: "device.connect",
478
- hostDeviceId,
381
+ type: "session.connect",
382
+ sessionId,
479
383
  payload: {
480
384
  role,
481
385
  clientName: deviceId,
@@ -495,18 +399,18 @@ export function startEmbeddedGateway(
495
399
  socket,
496
400
  data.toString(),
497
401
  role,
498
- hostDeviceId,
402
+ sessionId,
499
403
  deviceId,
500
404
  sessionManager,
501
405
  );
502
406
  } catch (err) {
503
- log("error", `unhandled websocket message error for host device ${hostDeviceId}: ${err instanceof Error ? err.message : String(err)}`);
407
+ log("error", `unhandled websocket message error for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
504
408
  if (socket.readyState === socket.OPEN) {
505
409
  socket.send(
506
410
  serializeEnvelope(
507
411
  createEnvelope({
508
- type: "device.error",
509
- hostDeviceId,
412
+ type: "session.error",
413
+ sessionId,
510
414
  payload: {
511
415
  code: "invalid_message",
512
416
  message: "Failed to handle message",
@@ -521,13 +425,12 @@ export function startEmbeddedGateway(
521
425
  socket.on("close", () => {
522
426
  clearInterval(pingTimer);
523
427
  if (role === "host") {
524
- const result = sessionManager.removeHost(hostDeviceId);
525
- cleanupSessionTunnels(hostDeviceId);
428
+ const result = sessionManager.removeHost(sessionId);
526
429
  if (result) {
527
430
  const notification = serializeEnvelope(
528
431
  createEnvelope({
529
- type: "device.host_disconnected",
530
- hostDeviceId,
432
+ type: "session.host_disconnected",
433
+ sessionId,
531
434
  payload: { reason: "host connection closed" },
532
435
  }),
533
436
  );
@@ -537,7 +440,7 @@ export function startEmbeddedGateway(
537
440
  }
538
441
  }
539
442
  } else {
540
- sessionManager.removeClient(hostDeviceId, deviceId);
443
+ sessionManager.removeClient(sessionId, deviceId);
541
444
  }
542
445
  });
543
446
 
@@ -562,7 +465,6 @@ export function startEmbeddedGateway(
562
465
  sessionManager.destroy();
563
466
  pairingManager.destroy();
564
467
  tokenManager.destroy();
565
- eventLoopDelay.disable();
566
468
  server.close(() => res());
567
469
  }),
568
470
  });