@linkshell/gateway 0.3.2 → 0.3.3

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/src/index.ts CHANGED
@@ -48,7 +48,9 @@ await Promise.all([pairingManager.hydrate(), tokenManager.hydrate()]);
48
48
 
49
49
  const PING_INTERVAL = 20_000;
50
50
  const MAX_BODY_SIZE = 4096;
51
- const MAX_WS_MESSAGE_SIZE = 50 * 1024 * 1024; // 50MB (supports base64 image uploads)
51
+ const MAX_WS_MESSAGE_SIZE = Number(
52
+ process.env.MAX_WS_MESSAGE_SIZE ?? 16 * 1024 * 1024,
53
+ );
52
54
  const PAIRING_RATE_LIMIT_MAX = Number(process.env.PAIRING_RATE_LIMIT_MAX ?? 30);
53
55
  const PAIRING_RATE_LIMIT_WINDOW_MS = Number(
54
56
  process.env.PAIRING_RATE_LIMIT_WINDOW_MS ?? 60_000,
@@ -179,7 +181,18 @@ async function handleRequest(
179
181
 
180
182
  // Health check
181
183
  if (method === "GET" && url.pathname === "/healthz") {
182
- json(res, 200, { ok: true });
184
+ const memory = process.memoryUsage();
185
+ json(res, 200, {
186
+ ok: true,
187
+ uptime: Math.round(process.uptime()),
188
+ memory: {
189
+ rss: memory.rss,
190
+ heapUsed: memory.heapUsed,
191
+ heapTotal: memory.heapTotal,
192
+ external: memory.external,
193
+ },
194
+ sessions: sessionManager.getStats(),
195
+ });
183
196
  return;
184
197
  }
185
198
 
package/src/relay.ts CHANGED
@@ -2,7 +2,6 @@ import type WebSocket from "ws";
2
2
  import {
3
3
  parseEnvelope,
4
4
  parseTypedPayload,
5
- protocolMessageSchemas,
6
5
  serializeEnvelope,
7
6
  createEnvelope,
8
7
  } from "@linkshell/protocol";
@@ -49,7 +48,7 @@ export function handleSocketMessage(
49
48
  }
50
49
 
51
50
  try {
52
- if (isProtocolMessageType(envelope.type)) {
51
+ if (shouldValidatePayload(envelope.type)) {
53
52
  envelope = {
54
53
  ...envelope,
55
54
  payload: parseTypedPayload(envelope.type, envelope.payload),
@@ -57,9 +56,9 @@ export function handleSocketMessage(
57
56
  }
58
57
 
59
58
  if (role === "host") {
60
- handleHostMessage(envelope, session, sessions);
59
+ handleHostMessage(envelope, raw, session, sessions);
61
60
  } else {
62
- handleClientMessage(envelope, socket, session, deviceId, sessions);
61
+ handleClientMessage(envelope, raw, socket, session, deviceId, sessions);
63
62
  }
64
63
  } catch (error) {
65
64
  if (error instanceof ZodError) {
@@ -75,8 +74,18 @@ export function handleSocketMessage(
75
74
  }
76
75
  }
77
76
 
78
- function isProtocolMessageType(type: string): type is ProtocolMessageType {
79
- return Object.prototype.hasOwnProperty.call(protocolMessageSchemas, type);
77
+ function shouldValidatePayload(type: string): type is ProtocolMessageType {
78
+ return (
79
+ type === "device.connect" ||
80
+ type === "device.ack" ||
81
+ type === "device.resume" ||
82
+ type === "terminal.input" ||
83
+ type === "terminal.resize" ||
84
+ type === "permission.decision" ||
85
+ type === "permission.decision.result" ||
86
+ type === "control.claim" ||
87
+ type === "control.release"
88
+ );
80
89
  }
81
90
 
82
91
  function sendSessionError(
@@ -99,6 +108,7 @@ function sendSessionError(
99
108
 
100
109
  function handleHostMessage(
101
110
  envelope: Envelope,
111
+ raw: string,
102
112
  session: ReturnType<DeviceManager["get"]> & {},
103
113
  sessions: DeviceManager,
104
114
  ): void {
@@ -122,12 +132,12 @@ function handleHostMessage(
122
132
  }
123
133
  case "terminal.output": {
124
134
  sessions.bufferOutput(session.id, envelope);
125
- broadcastToClients(session, envelope);
135
+ broadcastToClients(session, envelope, raw);
126
136
  break;
127
137
  }
128
138
  case "terminal.exit": {
129
139
  // Don't terminate session — other terminals may still be running
130
- broadcastToClients(session, envelope);
140
+ broadcastToClients(session, envelope, raw);
131
141
  break;
132
142
  }
133
143
  case "device.heartbeat":
@@ -145,7 +155,7 @@ function handleHostMessage(
145
155
  message: p.message,
146
156
  },
147
157
  });
148
- broadcastToClients(session, envelope);
158
+ broadcastToClients(session, envelope, raw);
149
159
  break;
150
160
  }
151
161
  // Tunnel: host → gateway (not broadcast to clients)
@@ -166,7 +176,7 @@ function handleHostMessage(
166
176
  }
167
177
  case "control.grant":
168
178
  case "control.reject":
169
- broadcastToClients(session, envelope);
179
+ broadcastToClients(session, envelope, raw);
170
180
  break;
171
181
  // Screen sharing: host → clients
172
182
  case "screen.frame":
@@ -185,21 +195,22 @@ function handleHostMessage(
185
195
  case "terminal.list":
186
196
  case "terminal.browse.result":
187
197
  case "terminal.file.read.result":
188
- broadcastToClients(session, envelope);
198
+ broadcastToClients(session, envelope, raw);
189
199
  break;
190
200
  // Structured status from hooks
191
201
  case "terminal.status":
192
202
  sessions.cacheStatus(session.id, envelope);
193
- broadcastToClients(session, envelope);
203
+ broadcastToClients(session, envelope, raw);
194
204
  break;
195
205
  default:
196
- broadcastToClients(session, envelope);
206
+ broadcastToClients(session, envelope, raw);
197
207
  break;
198
208
  }
199
209
  }
200
210
 
201
211
  function handleClientMessage(
202
212
  envelope: Envelope,
213
+ raw: string,
203
214
  socket: WebSocket,
204
215
  session: ReturnType<DeviceManager["get"]> & {},
205
216
  deviceId: string,
@@ -225,17 +236,17 @@ function handleClientMessage(
225
236
  switch (envelope.type) {
226
237
  case "terminal.input": {
227
238
  if (!requireController()) return;
228
- sendToHost(session, envelope);
239
+ sendToHost(session, envelope, raw);
229
240
  break;
230
241
  }
231
242
  case "terminal.resize": {
232
243
  if (!requireController()) return;
233
- sendToHost(session, envelope);
244
+ sendToHost(session, envelope, raw);
234
245
  break;
235
246
  }
236
247
  case "device.ack": {
237
248
  // Forward ACK to host
238
- sendToHost(session, envelope);
249
+ sendToHost(session, envelope, raw);
239
250
  break;
240
251
  }
241
252
  case "device.resume": {
@@ -324,15 +335,15 @@ function handleClientMessage(
324
335
  case "file.upload":
325
336
  case "permission.decision":
326
337
  if (!requireController()) return;
327
- sendToHost(session, envelope);
338
+ sendToHost(session, envelope, raw);
328
339
  break;
329
340
  case "agent.v2.capabilities.request":
330
341
  case "agent.v2.conversation.list":
331
342
  case "agent.v2.snapshot.request":
332
- sendToHost(session, envelope);
343
+ sendToHost(session, envelope, raw);
333
344
  break;
334
345
  default:
335
- sendToHost(session, envelope);
346
+ sendToHost(session, envelope, raw);
336
347
  break;
337
348
  }
338
349
  }
@@ -340,8 +351,9 @@ function handleClientMessage(
340
351
  function broadcastToClients(
341
352
  session: ReturnType<DeviceManager["get"]> & {},
342
353
  envelope: Envelope,
354
+ raw?: string,
343
355
  ): void {
344
- const data = serializeEnvelope(envelope);
356
+ const data = raw ?? serializeEnvelope(envelope);
345
357
  for (const [, client] of session.clients) {
346
358
  if (client.socket.readyState === client.socket.OPEN) {
347
359
  client.socket.send(data);
@@ -352,11 +364,12 @@ function broadcastToClients(
352
364
  function sendToHost(
353
365
  session: ReturnType<DeviceManager["get"]> & {},
354
366
  envelope: Envelope,
367
+ raw?: string,
355
368
  ): void {
356
369
  if (
357
370
  session.host &&
358
371
  session.host.socket.readyState === session.host.socket.OPEN
359
372
  ) {
360
- session.host.socket.send(serializeEnvelope(envelope));
373
+ session.host.socket.send(raw ?? serializeEnvelope(envelope));
361
374
  }
362
375
  }
package/src/sessions.ts CHANGED
@@ -33,6 +33,9 @@ export interface HostDevice {
33
33
  }
34
34
 
35
35
  const OUTPUT_BUFFER_CAPACITY = 200;
36
+ const OUTPUT_BUFFER_MAX_PAYLOAD_BYTES = Number(
37
+ process.env.OUTPUT_BUFFER_MAX_PAYLOAD_BYTES ?? 64 * 1024,
38
+ );
36
39
  const HOST_RECONNECT_WINDOW = 60_000;
37
40
  const CLEANUP_INTERVAL = 30_000;
38
41
 
@@ -154,6 +157,14 @@ export class DeviceManager {
154
157
  bufferOutput(hostDeviceId: string, envelope: Envelope): void {
155
158
  const device = this.devices.get(hostDeviceId);
156
159
  if (!device) return;
160
+ const payload = envelope.payload as { data?: unknown } | undefined;
161
+ if (
162
+ typeof payload?.data === "string" &&
163
+ Buffer.byteLength(payload.data, "utf8") > OUTPUT_BUFFER_MAX_PAYLOAD_BYTES
164
+ ) {
165
+ device.lastActivity = Date.now();
166
+ return;
167
+ }
157
168
  const terminalId = envelope.terminalId ?? "default";
158
169
  let buffer = device.outputBuffers.get(terminalId);
159
170
  if (!buffer) {
@@ -257,6 +268,24 @@ export class DeviceManager {
257
268
  };
258
269
  }
259
270
 
271
+ getStats() {
272
+ let clientCount = 0;
273
+ let bufferedTerminalFrames = 0;
274
+ let terminalCount = 0;
275
+ for (const device of this.devices.values()) {
276
+ clientCount += device.clients.size;
277
+ terminalCount += device.outputBuffers.size;
278
+ bufferedTerminalFrames += [...device.outputBuffers.values()].reduce((sum, buf) => sum + buf.length, 0);
279
+ }
280
+ return {
281
+ devices: this.devices.size,
282
+ activeDevices: this.listActive().length,
283
+ clients: clientCount,
284
+ terminalsWithReplay: terminalCount,
285
+ bufferedTerminalFrames,
286
+ };
287
+ }
288
+
260
289
  setMetadata(
261
290
  hostDeviceId: string,
262
291
  _provider?: string,