@linkshell/gateway 0.1.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.
- package/Dockerfile +37 -0
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/gateway/src/embedded.d.ts +16 -0
- package/dist/gateway/src/embedded.js +239 -0
- package/dist/gateway/src/embedded.js.map +1 -0
- package/dist/gateway/src/index.d.ts +1 -0
- package/dist/gateway/src/index.js +332 -0
- package/dist/gateway/src/index.js.map +1 -0
- package/dist/gateway/src/pairings.d.ts +26 -0
- package/dist/gateway/src/pairings.js +61 -0
- package/dist/gateway/src/pairings.js.map +1 -0
- package/dist/gateway/src/relay.d.ts +3 -0
- package/dist/gateway/src/relay.js +156 -0
- package/dist/gateway/src/relay.js.map +1 -0
- package/dist/gateway/src/sessions.d.ts +57 -0
- package/dist/gateway/src/sessions.js +165 -0
- package/dist/gateway/src/sessions.js.map +1 -0
- package/dist/gateway/tsconfig.tsbuildinfo +1 -0
- package/dist/shared-protocol/src/index.d.ts +380 -0
- package/dist/shared-protocol/src/index.js +158 -0
- package/dist/shared-protocol/src/index.js.map +1 -0
- package/package.json +44 -0
- package/src/embedded.ts +282 -0
- package/src/index.ts +415 -0
- package/src/pairings.ts +75 -0
- package/src/relay.ts +209 -0
- package/src/sessions.ts +205 -0
package/src/embedded.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import type { IncomingMessage, ServerResponse, Server } from "node:http";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { WebSocketServer } from "ws";
|
|
5
|
+
import type WebSocket from "ws";
|
|
6
|
+
import {
|
|
7
|
+
createEnvelope,
|
|
8
|
+
serializeEnvelope,
|
|
9
|
+
PROTOCOL_VERSION,
|
|
10
|
+
} from "@linkshell/protocol";
|
|
11
|
+
import { z, ZodError } from "zod";
|
|
12
|
+
import { SessionManager } from "./sessions.js";
|
|
13
|
+
import { PairingManager } from "./pairings.js";
|
|
14
|
+
import { handleSocketMessage } from "./relay.js";
|
|
15
|
+
|
|
16
|
+
export interface EmbeddedGatewayOptions {
|
|
17
|
+
port?: number;
|
|
18
|
+
logLevel?: "debug" | "info" | "warn" | "error";
|
|
19
|
+
silent?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface EmbeddedGateway {
|
|
23
|
+
port: number;
|
|
24
|
+
httpUrl: string;
|
|
25
|
+
wsUrl: string;
|
|
26
|
+
close: () => Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PING_INTERVAL = 20_000;
|
|
30
|
+
const MAX_BODY_SIZE = 4096;
|
|
31
|
+
const MAX_WS_MESSAGE_SIZE = 64 * 1024;
|
|
32
|
+
|
|
33
|
+
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
34
|
+
|
|
35
|
+
const createPairingBody = z.object({ sessionId: z.string().optional() });
|
|
36
|
+
const claimPairingBody = z.object({ pairingCode: z.string().length(6) });
|
|
37
|
+
|
|
38
|
+
class BodyTooLargeError extends Error {}
|
|
39
|
+
|
|
40
|
+
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
41
|
+
res.writeHead(status, {
|
|
42
|
+
"content-type": "application/json",
|
|
43
|
+
"access-control-allow-origin": "*",
|
|
44
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
45
|
+
"access-control-allow-headers": "Content-Type, Authorization",
|
|
46
|
+
});
|
|
47
|
+
res.end(JSON.stringify(body));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readJson(req: IncomingMessage): Promise<unknown> {
|
|
51
|
+
const chunks: Buffer[] = [];
|
|
52
|
+
let size = 0;
|
|
53
|
+
for await (const chunk of req) {
|
|
54
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
55
|
+
size += buf.length;
|
|
56
|
+
if (size > MAX_BODY_SIZE) throw new BodyTooLargeError();
|
|
57
|
+
chunks.push(buf);
|
|
58
|
+
}
|
|
59
|
+
if (chunks.length === 0) return {};
|
|
60
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getClientIp(req: IncomingMessage): string {
|
|
64
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
65
|
+
if (typeof forwarded === "string") return forwarded.split(",")[0]!.trim();
|
|
66
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Start an embedded gateway. Returns a handle to get URLs and close it.
|
|
71
|
+
* Used by CLI when no external --gateway is provided.
|
|
72
|
+
*/
|
|
73
|
+
export function startEmbeddedGateway(options: EmbeddedGatewayOptions = {}): Promise<EmbeddedGateway> {
|
|
74
|
+
const targetPort = options.port ?? 0; // 0 = random available port
|
|
75
|
+
const logLevel = options.logLevel ?? "warn";
|
|
76
|
+
const silent = options.silent ?? false;
|
|
77
|
+
|
|
78
|
+
function log(level: "debug" | "info" | "warn" | "error", msg: string): void {
|
|
79
|
+
if (silent) return;
|
|
80
|
+
if (LOG_LEVELS[level] >= LOG_LEVELS[logLevel]) {
|
|
81
|
+
process.stderr.write(`[gateway:${level}] ${msg}\n`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const sessionManager = new SessionManager();
|
|
86
|
+
const pairingManager = new PairingManager();
|
|
87
|
+
|
|
88
|
+
const server = createServer(async (req, res) => {
|
|
89
|
+
if (req.method === "OPTIONS") {
|
|
90
|
+
res.writeHead(204, {
|
|
91
|
+
"access-control-allow-origin": "*",
|
|
92
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
93
|
+
"access-control-allow-headers": "Content-Type, Authorization",
|
|
94
|
+
"access-control-max-age": "86400",
|
|
95
|
+
});
|
|
96
|
+
res.end();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
102
|
+
const method = req.method ?? "GET";
|
|
103
|
+
|
|
104
|
+
if (method === "GET" && url.pathname === "/healthz") {
|
|
105
|
+
json(res, 200, { ok: true });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (method === "POST" && url.pathname === "/pairings") {
|
|
110
|
+
const body = createPairingBody.parse(await readJson(req));
|
|
111
|
+
const record = pairingManager.create(body.sessionId);
|
|
112
|
+
json(res, 201, {
|
|
113
|
+
sessionId: record.sessionId,
|
|
114
|
+
pairingCode: record.pairingCode,
|
|
115
|
+
expiresAt: new Date(record.expiresAt).toISOString(),
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (method === "POST" && url.pathname === "/pairings/claim") {
|
|
121
|
+
const body = claimPairingBody.parse(await readJson(req));
|
|
122
|
+
const result = pairingManager.claim(body.pairingCode);
|
|
123
|
+
if ("error" in result) {
|
|
124
|
+
json(res, result.status, { error: result.error });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
json(res, 200, { sessionId: result.sessionId });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (method === "GET" && url.pathname === "/sessions") {
|
|
132
|
+
const sessions = sessionManager.listActive().map((s) => ({
|
|
133
|
+
id: s.id,
|
|
134
|
+
state: s.state,
|
|
135
|
+
hasHost: !!s.host,
|
|
136
|
+
clientCount: s.clients.size,
|
|
137
|
+
controllerId: s.controllerId ?? null,
|
|
138
|
+
lastActivity: s.lastActivity,
|
|
139
|
+
createdAt: s.createdAt,
|
|
140
|
+
provider: s.provider ?? null,
|
|
141
|
+
hostname: s.hostname ?? null,
|
|
142
|
+
}));
|
|
143
|
+
json(res, 200, { sessions });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
|
|
148
|
+
if (method === "GET" && sessionMatch) {
|
|
149
|
+
const summary = sessionManager.getSummary(sessionMatch[1]!);
|
|
150
|
+
if (!summary) {
|
|
151
|
+
json(res, 404, { error: "session_not_found" });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
json(res, 200, summary);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const pairingMatch = url.pathname.match(/^\/pairings\/(\d{6})\/status$/);
|
|
159
|
+
if (method === "GET" && pairingMatch) {
|
|
160
|
+
const result = pairingManager.getStatus(pairingMatch[1]!);
|
|
161
|
+
if ("error" in result) {
|
|
162
|
+
json(res, result.httpStatus, { error: result.error });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
json(res, 200, result);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
json(res, 404, { error: "not_found" });
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err instanceof ZodError) {
|
|
172
|
+
json(res, 400, { error: "invalid_message", message: err.errors[0]?.message ?? "Validation failed" });
|
|
173
|
+
} else if (err instanceof BodyTooLargeError) {
|
|
174
|
+
json(res, 413, { error: "body_too_large", message: "Request body exceeds limit" });
|
|
175
|
+
} else if (err instanceof SyntaxError) {
|
|
176
|
+
json(res, 400, { error: "invalid_json", message: "Malformed JSON" });
|
|
177
|
+
} else {
|
|
178
|
+
log("error", `unhandled: ${err}`);
|
|
179
|
+
json(res, 500, { error: "internal_error", message: "Internal server error" });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_MESSAGE_SIZE });
|
|
185
|
+
|
|
186
|
+
server.on("upgrade", (request, socket, head) => {
|
|
187
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host}`);
|
|
188
|
+
if (url.pathname !== "/ws") {
|
|
189
|
+
socket.destroy();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
193
|
+
wss.emit("connection", ws, request, url);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
wss.on("connection", (socket: WebSocket, _request: IncomingMessage, url: URL) => {
|
|
198
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
199
|
+
const role = url.searchParams.get("role") as "host" | "client" | null;
|
|
200
|
+
|
|
201
|
+
if (!sessionId || !role || (role !== "host" && role !== "client")) {
|
|
202
|
+
socket.close(1008, "missing sessionId or role");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const deviceId = url.searchParams.get("deviceId") ?? randomUUID();
|
|
207
|
+
const device = { socket, role, deviceId, connectedAt: Date.now() };
|
|
208
|
+
|
|
209
|
+
if (role === "host") {
|
|
210
|
+
const existingSession = sessionManager.get(sessionId);
|
|
211
|
+
const isReconnect = existingSession && existingSession.clients.size > 0 && existingSession.state === "host_disconnected";
|
|
212
|
+
sessionManager.setHost(sessionId, device);
|
|
213
|
+
if (isReconnect) {
|
|
214
|
+
const notification = serializeEnvelope(
|
|
215
|
+
createEnvelope({ type: "session.host_reconnected", sessionId, payload: {} }),
|
|
216
|
+
);
|
|
217
|
+
for (const [, client] of existingSession.clients) {
|
|
218
|
+
if (client.socket.readyState === client.socket.OPEN) client.socket.send(notification);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
sessionManager.addClient(sessionId, device);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
socket.send(
|
|
226
|
+
serializeEnvelope(
|
|
227
|
+
createEnvelope({
|
|
228
|
+
type: "session.connect",
|
|
229
|
+
sessionId,
|
|
230
|
+
payload: { role, clientName: deviceId, protocolVersion: PROTOCOL_VERSION },
|
|
231
|
+
}),
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const pingTimer = setInterval(() => {
|
|
236
|
+
if (socket.readyState === socket.OPEN) socket.ping();
|
|
237
|
+
}, PING_INTERVAL);
|
|
238
|
+
|
|
239
|
+
socket.on("message", (data: WebSocket.RawData) => {
|
|
240
|
+
handleSocketMessage(socket, data.toString(), role, sessionId, deviceId, sessionManager);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
socket.on("close", () => {
|
|
244
|
+
clearInterval(pingTimer);
|
|
245
|
+
if (role === "host") {
|
|
246
|
+
const result = sessionManager.removeHost(sessionId);
|
|
247
|
+
if (result) {
|
|
248
|
+
const notification = serializeEnvelope(
|
|
249
|
+
createEnvelope({ type: "session.host_disconnected", sessionId, payload: { reason: "host connection closed" } }),
|
|
250
|
+
);
|
|
251
|
+
for (const [, client] of result.clients) {
|
|
252
|
+
if (client.socket.readyState === client.socket.OPEN) client.socket.send(notification);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
sessionManager.removeClient(sessionId, deviceId);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
socket.on("error", () => {});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return new Promise<EmbeddedGateway>((resolve, reject) => {
|
|
264
|
+
server.on("error", reject);
|
|
265
|
+
server.listen(targetPort, () => {
|
|
266
|
+
const addr = server.address();
|
|
267
|
+
const actualPort = typeof addr === "object" && addr ? addr.port : targetPort;
|
|
268
|
+
log("info", `embedded gateway on port ${actualPort}`);
|
|
269
|
+
resolve({
|
|
270
|
+
port: actualPort,
|
|
271
|
+
httpUrl: `http://127.0.0.1:${actualPort}`,
|
|
272
|
+
wsUrl: `ws://127.0.0.1:${actualPort}/ws`,
|
|
273
|
+
close: () => new Promise<void>((res) => {
|
|
274
|
+
wss.clients.forEach((ws) => ws.close(1001, "shutting down"));
|
|
275
|
+
sessionManager.destroy();
|
|
276
|
+
pairingManager.destroy();
|
|
277
|
+
server.close(() => res());
|
|
278
|
+
}),
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { WebSocketServer } from "ws";
|
|
5
|
+
import type WebSocket from "ws";
|
|
6
|
+
import {
|
|
7
|
+
createEnvelope,
|
|
8
|
+
serializeEnvelope,
|
|
9
|
+
PROTOCOL_VERSION,
|
|
10
|
+
} from "@linkshell/protocol";
|
|
11
|
+
import { z, ZodError } from "zod";
|
|
12
|
+
import { SessionManager } from "./sessions.js";
|
|
13
|
+
import { PairingManager } from "./pairings.js";
|
|
14
|
+
import { handleSocketMessage } from "./relay.js";
|
|
15
|
+
|
|
16
|
+
const port = Number(process.env.PORT ?? 8787);
|
|
17
|
+
const logLevel = (process.env.LOG_LEVEL ?? "info") as
|
|
18
|
+
| "debug"
|
|
19
|
+
| "info"
|
|
20
|
+
| "warn"
|
|
21
|
+
| "error";
|
|
22
|
+
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
23
|
+
|
|
24
|
+
function log(level: "debug" | "info" | "warn" | "error", msg: string): void {
|
|
25
|
+
if (LOG_LEVELS[level] >= LOG_LEVELS[logLevel]) {
|
|
26
|
+
process.stdout.write(`[gateway:${level}] ${msg}\n`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sessionManager = new SessionManager();
|
|
31
|
+
const pairingManager = new PairingManager();
|
|
32
|
+
|
|
33
|
+
const PING_INTERVAL = 20_000;
|
|
34
|
+
const MAX_BODY_SIZE = 4096;
|
|
35
|
+
const MAX_WS_MESSAGE_SIZE = 64 * 1024; // 64KB
|
|
36
|
+
const PAIRING_RATE_LIMIT_MAX = Number(process.env.PAIRING_RATE_LIMIT_MAX ?? 30);
|
|
37
|
+
const PAIRING_RATE_LIMIT_WINDOW_MS = Number(
|
|
38
|
+
process.env.PAIRING_RATE_LIMIT_WINDOW_MS ?? 60_000,
|
|
39
|
+
);
|
|
40
|
+
const WS_CONNECT_RATE_LIMIT_MAX = Number(
|
|
41
|
+
process.env.WS_CONNECT_RATE_LIMIT_MAX ?? 20,
|
|
42
|
+
);
|
|
43
|
+
const WS_CONNECT_RATE_LIMIT_WINDOW_MS = Number(
|
|
44
|
+
process.env.WS_CONNECT_RATE_LIMIT_WINDOW_MS ?? 60_000,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// ── Rate limiter ────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
class RateLimiter {
|
|
50
|
+
private hits = new Map<string, { count: number; resetAt: number }>();
|
|
51
|
+
constructor(
|
|
52
|
+
private maxHits: number,
|
|
53
|
+
private windowMs: number,
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
allow(key: string): boolean {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const entry = this.hits.get(key);
|
|
59
|
+
if (!entry || now >= entry.resetAt) {
|
|
60
|
+
this.hits.set(key, { count: 1, resetAt: now + this.windowMs });
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
entry.count++;
|
|
64
|
+
return entry.count <= this.maxHits;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const pairingLimiter = new RateLimiter(
|
|
69
|
+
PAIRING_RATE_LIMIT_MAX,
|
|
70
|
+
PAIRING_RATE_LIMIT_WINDOW_MS,
|
|
71
|
+
);
|
|
72
|
+
const wsConnectLimiter = new RateLimiter(
|
|
73
|
+
WS_CONNECT_RATE_LIMIT_MAX,
|
|
74
|
+
WS_CONNECT_RATE_LIMIT_WINDOW_MS,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
function isLoopbackIp(ip: string): boolean {
|
|
78
|
+
const normalized = ip.trim().toLowerCase();
|
|
79
|
+
return (
|
|
80
|
+
normalized === "127.0.0.1" ||
|
|
81
|
+
normalized === "::1" ||
|
|
82
|
+
normalized === "::ffff:127.0.0.1"
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isRateLimitBypassed(ip: string): boolean {
|
|
87
|
+
return isLoopbackIp(ip);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getClientIp(req: IncomingMessage): string {
|
|
91
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
92
|
+
if (typeof forwarded === "string") return forwarded.split(",")[0]!.trim();
|
|
93
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── CORS ────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function setCors(res: ServerResponse): void {
|
|
99
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
100
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
101
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
102
|
+
res.setHeader("Access-Control-Max-Age", "86400");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── HTTP API ────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
const createPairingBody = z.object({ sessionId: z.string().optional() });
|
|
108
|
+
const claimPairingBody = z.object({ pairingCode: z.string().length(6) });
|
|
109
|
+
|
|
110
|
+
const server = createServer(async (req, res) => {
|
|
111
|
+
setCors(res);
|
|
112
|
+
|
|
113
|
+
if (req.method === "OPTIONS") {
|
|
114
|
+
res.writeHead(204);
|
|
115
|
+
res.end();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await handleRequest(req, res);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (err instanceof ZodError) {
|
|
123
|
+
json(res, 400, {
|
|
124
|
+
error: "invalid_message",
|
|
125
|
+
message: err.errors[0]?.message ?? "Validation failed",
|
|
126
|
+
});
|
|
127
|
+
} else if (err instanceof BodyTooLargeError) {
|
|
128
|
+
json(res, 413, {
|
|
129
|
+
error: "body_too_large",
|
|
130
|
+
message: "Request body exceeds limit",
|
|
131
|
+
});
|
|
132
|
+
} else if (err instanceof SyntaxError) {
|
|
133
|
+
json(res, 400, { error: "invalid_json", message: "Malformed JSON" });
|
|
134
|
+
} else {
|
|
135
|
+
process.stderr.write(`[gateway] unhandled error: ${err}\n`);
|
|
136
|
+
json(res, 500, {
|
|
137
|
+
error: "internal_error",
|
|
138
|
+
message: "Internal server error",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
async function handleRequest(
|
|
145
|
+
req: IncomingMessage,
|
|
146
|
+
res: ServerResponse,
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
149
|
+
const method = req.method ?? "GET";
|
|
150
|
+
const ip = getClientIp(req);
|
|
151
|
+
|
|
152
|
+
// Health check
|
|
153
|
+
if (method === "GET" && url.pathname === "/healthz") {
|
|
154
|
+
json(res, 200, { ok: true });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Create pairing
|
|
159
|
+
if (method === "POST" && url.pathname === "/pairings") {
|
|
160
|
+
if (!isRateLimitBypassed(ip) && !pairingLimiter.allow(ip)) {
|
|
161
|
+
json(res, 429, { error: "rate_limited", message: "Too many requests" });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const body = createPairingBody.parse(await readJson(req));
|
|
165
|
+
const record = pairingManager.create(body.sessionId);
|
|
166
|
+
json(res, 201, {
|
|
167
|
+
sessionId: record.sessionId,
|
|
168
|
+
pairingCode: record.pairingCode,
|
|
169
|
+
expiresAt: new Date(record.expiresAt).toISOString(),
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Claim pairing
|
|
175
|
+
if (method === "POST" && url.pathname === "/pairings/claim") {
|
|
176
|
+
if (!isRateLimitBypassed(ip) && !pairingLimiter.allow(ip)) {
|
|
177
|
+
json(res, 429, { error: "rate_limited", message: "Too many requests" });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const body = claimPairingBody.parse(await readJson(req));
|
|
181
|
+
const result = pairingManager.claim(body.pairingCode);
|
|
182
|
+
if ("error" in result) {
|
|
183
|
+
json(res, result.status, { error: result.error });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
json(res, 200, { sessionId: result.sessionId });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Session list
|
|
191
|
+
if (method === "GET" && url.pathname === "/sessions") {
|
|
192
|
+
const sessions = sessionManager.listActive().map((s) => ({
|
|
193
|
+
id: s.id,
|
|
194
|
+
state: s.state,
|
|
195
|
+
hasHost: !!s.host,
|
|
196
|
+
clientCount: s.clients.size,
|
|
197
|
+
controllerId: s.controllerId ?? null,
|
|
198
|
+
lastActivity: s.lastActivity,
|
|
199
|
+
createdAt: s.createdAt,
|
|
200
|
+
provider: s.provider ?? null,
|
|
201
|
+
hostname: s.hostname ?? null,
|
|
202
|
+
}));
|
|
203
|
+
json(res, 200, { sessions });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Session detail
|
|
208
|
+
const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
|
|
209
|
+
if (method === "GET" && sessionMatch) {
|
|
210
|
+
const summary = sessionManager.getSummary(sessionMatch[1]!);
|
|
211
|
+
if (!summary) {
|
|
212
|
+
json(res, 404, { error: "session_not_found" });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
json(res, 200, summary);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Pairing status (for CLI polling)
|
|
220
|
+
const pairingMatch = url.pathname.match(/^\/pairings\/(\d{6})\/status$/);
|
|
221
|
+
if (method === "GET" && pairingMatch) {
|
|
222
|
+
const result = pairingManager.getStatus(pairingMatch[1]!);
|
|
223
|
+
if ("error" in result) {
|
|
224
|
+
json(res, result.httpStatus, { error: result.error });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
json(res, 200, result);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
json(res, 404, { error: "not_found" });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── WebSocket ───────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
const wss = new WebSocketServer({
|
|
237
|
+
noServer: true,
|
|
238
|
+
maxPayload: MAX_WS_MESSAGE_SIZE,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
server.on("upgrade", (request, socket, head) => {
|
|
242
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host}`);
|
|
243
|
+
if (url.pathname !== "/ws") {
|
|
244
|
+
socket.destroy();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const ip = getClientIp(request);
|
|
249
|
+
if (!isRateLimitBypassed(ip) && !wsConnectLimiter.allow(ip)) {
|
|
250
|
+
socket.write("HTTP/1.1 429 Too Many Requests\r\n\r\n");
|
|
251
|
+
socket.destroy();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
256
|
+
wss.emit("connection", ws, request, url);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
wss.on(
|
|
261
|
+
"connection",
|
|
262
|
+
(socket: WebSocket, _request: IncomingMessage, url: URL) => {
|
|
263
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
264
|
+
const role = url.searchParams.get("role") as "host" | "client" | null;
|
|
265
|
+
|
|
266
|
+
if (!sessionId || !role || (role !== "host" && role !== "client")) {
|
|
267
|
+
socket.close(1008, "missing sessionId or role");
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const deviceId = url.searchParams.get("deviceId") ?? randomUUID();
|
|
272
|
+
|
|
273
|
+
const device = {
|
|
274
|
+
socket,
|
|
275
|
+
role,
|
|
276
|
+
deviceId,
|
|
277
|
+
connectedAt: Date.now(),
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
if (role === "host") {
|
|
281
|
+
// Check if this is a reconnect (session already exists with clients)
|
|
282
|
+
const existingSession = sessionManager.get(sessionId);
|
|
283
|
+
const isReconnect =
|
|
284
|
+
existingSession &&
|
|
285
|
+
existingSession.clients.size > 0 &&
|
|
286
|
+
existingSession.state === "host_disconnected";
|
|
287
|
+
sessionManager.setHost(sessionId, device);
|
|
288
|
+
if (isReconnect) {
|
|
289
|
+
const notification = serializeEnvelope(
|
|
290
|
+
createEnvelope({
|
|
291
|
+
type: "session.host_reconnected",
|
|
292
|
+
sessionId,
|
|
293
|
+
payload: {},
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
for (const [, client] of existingSession.clients) {
|
|
297
|
+
if (client.socket.readyState === client.socket.OPEN) {
|
|
298
|
+
client.socket.send(notification);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
sessionManager.addClient(sessionId, device);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Send welcome with protocol version
|
|
307
|
+
socket.send(
|
|
308
|
+
serializeEnvelope(
|
|
309
|
+
createEnvelope({
|
|
310
|
+
type: "session.connect",
|
|
311
|
+
sessionId,
|
|
312
|
+
payload: {
|
|
313
|
+
role,
|
|
314
|
+
clientName: deviceId,
|
|
315
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
316
|
+
},
|
|
317
|
+
}),
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// Ping/pong for liveness
|
|
322
|
+
const pingTimer = setInterval(() => {
|
|
323
|
+
if (socket.readyState === socket.OPEN) {
|
|
324
|
+
socket.ping();
|
|
325
|
+
}
|
|
326
|
+
}, PING_INTERVAL);
|
|
327
|
+
|
|
328
|
+
socket.on("message", (data: WebSocket.RawData) => {
|
|
329
|
+
handleSocketMessage(
|
|
330
|
+
socket,
|
|
331
|
+
data.toString(),
|
|
332
|
+
role,
|
|
333
|
+
sessionId,
|
|
334
|
+
deviceId,
|
|
335
|
+
sessionManager,
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
socket.on("close", () => {
|
|
340
|
+
clearInterval(pingTimer);
|
|
341
|
+
if (role === "host") {
|
|
342
|
+
const result = sessionManager.removeHost(sessionId);
|
|
343
|
+
// Notify all clients that host disconnected
|
|
344
|
+
if (result) {
|
|
345
|
+
const notification = serializeEnvelope(
|
|
346
|
+
createEnvelope({
|
|
347
|
+
type: "session.host_disconnected",
|
|
348
|
+
sessionId,
|
|
349
|
+
payload: { reason: "host connection closed" },
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
for (const [, client] of result.clients) {
|
|
353
|
+
if (client.socket.readyState === client.socket.OPEN) {
|
|
354
|
+
client.socket.send(notification);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
sessionManager.removeClient(sessionId, deviceId);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
socket.on("error", () => {
|
|
364
|
+
// close will fire
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
// ── Graceful shutdown ───────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
function shutdown() {
|
|
372
|
+
process.stdout.write("[gateway] shutting down...\n");
|
|
373
|
+
wss.clients.forEach((ws) => ws.close(1001, "server shutting down"));
|
|
374
|
+
sessionManager.destroy();
|
|
375
|
+
pairingManager.destroy();
|
|
376
|
+
server.close(() => {
|
|
377
|
+
process.stdout.write("[gateway] stopped\n");
|
|
378
|
+
process.exit(0);
|
|
379
|
+
});
|
|
380
|
+
// Force exit after 5s
|
|
381
|
+
setTimeout(() => process.exit(1), 5000).unref();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
process.on("SIGINT", shutdown);
|
|
385
|
+
process.on("SIGTERM", shutdown);
|
|
386
|
+
|
|
387
|
+
// ── Start ───────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
server.listen(port, () => {
|
|
390
|
+
log("info", `LinkShell Gateway v0.1.0`);
|
|
391
|
+
log("info", `listening on http://0.0.0.0:${port}`);
|
|
392
|
+
log("info", `log level: ${logLevel}`);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
class BodyTooLargeError extends Error {}
|
|
398
|
+
|
|
399
|
+
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
400
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
401
|
+
res.end(JSON.stringify(body));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function readJson(req: IncomingMessage): Promise<unknown> {
|
|
405
|
+
const chunks: Buffer[] = [];
|
|
406
|
+
let size = 0;
|
|
407
|
+
for await (const chunk of req) {
|
|
408
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
409
|
+
size += buf.length;
|
|
410
|
+
if (size > MAX_BODY_SIZE) throw new BodyTooLargeError();
|
|
411
|
+
chunks.push(buf);
|
|
412
|
+
}
|
|
413
|
+
if (chunks.length === 0) return {};
|
|
414
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
415
|
+
}
|