@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.
@@ -0,0 +1,75 @@
1
+ import { randomInt, randomUUID } from "node:crypto";
2
+
3
+ export interface PairingRecord {
4
+ sessionId: string;
5
+ pairingCode: string;
6
+ expiresAt: number; // unix ms
7
+ claimed: boolean;
8
+ }
9
+
10
+ const PAIRING_TTL = 10 * 60_000; // 10 minutes
11
+ const CLEANUP_INTERVAL = 60_000;
12
+
13
+ export class PairingManager {
14
+ private pairings = new Map<string, PairingRecord>();
15
+ private cleanupTimer: ReturnType<typeof setInterval>;
16
+
17
+ constructor() {
18
+ this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
19
+ }
20
+
21
+ create(sessionId?: string): PairingRecord {
22
+ const id = sessionId ?? randomUUID();
23
+ const code = String(randomInt(100000, 999999));
24
+ const record: PairingRecord = {
25
+ sessionId: id,
26
+ pairingCode: code,
27
+ expiresAt: Date.now() + PAIRING_TTL,
28
+ claimed: false,
29
+ };
30
+ this.pairings.set(code, record);
31
+ return record;
32
+ }
33
+
34
+ claim(pairingCode: string): PairingRecord | { error: string; status: number } {
35
+ const record = this.pairings.get(pairingCode);
36
+ if (!record) {
37
+ return { error: "pairing_not_found", status: 404 };
38
+ }
39
+ if (record.expiresAt < Date.now()) {
40
+ this.pairings.delete(pairingCode);
41
+ return { error: "pairing_expired", status: 410 };
42
+ }
43
+ record.claimed = true;
44
+ return record;
45
+ }
46
+
47
+ getStatus(pairingCode: string): { status: string; expiresAt: number; sessionId: string } | { error: string; httpStatus: number } {
48
+ const record = this.pairings.get(pairingCode);
49
+ if (!record) {
50
+ return { error: "pairing_not_found", httpStatus: 404 };
51
+ }
52
+ if (record.expiresAt < Date.now()) {
53
+ this.pairings.delete(pairingCode);
54
+ return { error: "pairing_expired", httpStatus: 410 };
55
+ }
56
+ return {
57
+ status: record.claimed ? "claimed" : "waiting",
58
+ expiresAt: record.expiresAt,
59
+ sessionId: record.sessionId,
60
+ };
61
+ }
62
+
63
+ private cleanup(): void {
64
+ const now = Date.now();
65
+ for (const [code, record] of this.pairings) {
66
+ if (record.expiresAt < now) {
67
+ this.pairings.delete(code);
68
+ }
69
+ }
70
+ }
71
+
72
+ destroy(): void {
73
+ clearInterval(this.cleanupTimer);
74
+ }
75
+ }
package/src/relay.ts ADDED
@@ -0,0 +1,209 @@
1
+ import type WebSocket from "ws";
2
+ import {
3
+ parseEnvelope,
4
+ parseTypedPayload,
5
+ serializeEnvelope,
6
+ createEnvelope,
7
+ } from "@linkshell/protocol";
8
+ import type { Envelope } from "@linkshell/protocol";
9
+ import type { SessionManager, ConnectedDevice } from "./sessions.js";
10
+
11
+ export function handleSocketMessage(
12
+ socket: WebSocket,
13
+ raw: string,
14
+ role: "host" | "client",
15
+ sessionId: string,
16
+ deviceId: string,
17
+ sessions: SessionManager,
18
+ ): void {
19
+ let envelope: Envelope;
20
+ try {
21
+ envelope = parseEnvelope(raw);
22
+ } catch {
23
+ socket.send(
24
+ serializeEnvelope(
25
+ createEnvelope({
26
+ type: "session.error",
27
+ sessionId,
28
+ payload: { code: "invalid_message", message: "Failed to parse envelope" },
29
+ }),
30
+ ),
31
+ );
32
+ return;
33
+ }
34
+
35
+ const session = sessions.get(sessionId);
36
+ if (!session) {
37
+ socket.send(
38
+ serializeEnvelope(
39
+ createEnvelope({
40
+ type: "session.error",
41
+ sessionId,
42
+ payload: { code: "session_not_found", message: "Session not found" },
43
+ }),
44
+ ),
45
+ );
46
+ return;
47
+ }
48
+
49
+ if (role === "host") {
50
+ handleHostMessage(envelope, session, sessions);
51
+ } else {
52
+ handleClientMessage(envelope, socket, session, deviceId, sessions);
53
+ }
54
+ }
55
+
56
+ function handleHostMessage(
57
+ envelope: Envelope,
58
+ session: ReturnType<SessionManager["get"]> & {},
59
+ sessions: SessionManager,
60
+ ): void {
61
+ switch (envelope.type) {
62
+ case "session.connect": {
63
+ // Extract metadata from host's connect message
64
+ const p = parseTypedPayload("session.connect", envelope.payload);
65
+ if (p.provider || p.hostname) {
66
+ sessions.setMetadata(session.id, p.provider ?? undefined, p.hostname ?? undefined);
67
+ }
68
+ break;
69
+ }
70
+ case "terminal.output": {
71
+ sessions.bufferOutput(session.id, envelope);
72
+ broadcastToClients(session, envelope);
73
+ break;
74
+ }
75
+ case "terminal.exit": {
76
+ sessions.terminate(session.id);
77
+ broadcastToClients(session, envelope);
78
+ break;
79
+ }
80
+ case "session.heartbeat":
81
+ break;
82
+ case "control.grant":
83
+ case "control.reject":
84
+ broadcastToClients(session, envelope);
85
+ break;
86
+ default:
87
+ broadcastToClients(session, envelope);
88
+ break;
89
+ }
90
+ }
91
+
92
+ function handleClientMessage(
93
+ envelope: Envelope,
94
+ socket: WebSocket,
95
+ session: ReturnType<SessionManager["get"]> & {},
96
+ deviceId: string,
97
+ sessions: SessionManager,
98
+ ): void {
99
+ switch (envelope.type) {
100
+ case "terminal.input": {
101
+ // Only controller can send input
102
+ if (session.controllerId !== deviceId) {
103
+ socket.send(
104
+ serializeEnvelope(
105
+ createEnvelope({
106
+ type: "session.error",
107
+ sessionId: session.id,
108
+ payload: { code: "control_conflict", message: "Not the controller" },
109
+ }),
110
+ ),
111
+ );
112
+ return;
113
+ }
114
+ sendToHost(session, envelope);
115
+ break;
116
+ }
117
+ case "terminal.resize": {
118
+ if (session.controllerId !== deviceId) return;
119
+ sendToHost(session, envelope);
120
+ break;
121
+ }
122
+ case "session.ack": {
123
+ // Forward ACK to host
124
+ sendToHost(session, envelope);
125
+ break;
126
+ }
127
+ case "session.resume": {
128
+ const p = parseTypedPayload("session.resume", envelope.payload);
129
+ // Replay from gateway buffer first
130
+ const replay = sessions.getReplayFrom(session.id, p.lastAckedSeq);
131
+ for (const msg of replay) {
132
+ const payload = msg.payload as Record<string, unknown>;
133
+ socket.send(
134
+ serializeEnvelope(
135
+ createEnvelope({
136
+ type: "terminal.output",
137
+ sessionId: session.id,
138
+ seq: msg.seq,
139
+ payload: { ...payload, isReplay: true },
140
+ }),
141
+ ),
142
+ );
143
+ }
144
+ // Also forward resume to host so it can fill gaps beyond gateway buffer
145
+ sendToHost(session, envelope);
146
+ break;
147
+ }
148
+ case "control.claim": {
149
+ const granted = sessions.claimControl(session.id, deviceId);
150
+ if (granted) {
151
+ const grantMsg = createEnvelope({
152
+ type: "control.grant",
153
+ sessionId: session.id,
154
+ payload: { deviceId },
155
+ });
156
+ socket.send(serializeEnvelope(grantMsg));
157
+ sendToHost(session, grantMsg);
158
+ } else {
159
+ socket.send(
160
+ serializeEnvelope(
161
+ createEnvelope({
162
+ type: "control.reject",
163
+ sessionId: session.id,
164
+ payload: { deviceId, reason: "Another device holds control" },
165
+ }),
166
+ ),
167
+ );
168
+ }
169
+ break;
170
+ }
171
+ case "control.release": {
172
+ sessions.releaseControl(session.id, deviceId);
173
+ const releaseMsg = createEnvelope({
174
+ type: "control.release",
175
+ sessionId: session.id,
176
+ payload: { deviceId },
177
+ });
178
+ broadcastToClients(session, releaseMsg);
179
+ sendToHost(session, releaseMsg);
180
+ break;
181
+ }
182
+ case "session.heartbeat":
183
+ break;
184
+ default:
185
+ sendToHost(session, envelope);
186
+ break;
187
+ }
188
+ }
189
+
190
+ function broadcastToClients(
191
+ session: ReturnType<SessionManager["get"]> & {},
192
+ envelope: Envelope,
193
+ ): void {
194
+ const data = serializeEnvelope(envelope);
195
+ for (const [, client] of session.clients) {
196
+ if (client.socket.readyState === client.socket.OPEN) {
197
+ client.socket.send(data);
198
+ }
199
+ }
200
+ }
201
+
202
+ function sendToHost(
203
+ session: ReturnType<SessionManager["get"]> & {},
204
+ envelope: Envelope,
205
+ ): void {
206
+ if (session.host && session.host.socket.readyState === session.host.socket.OPEN) {
207
+ session.host.socket.send(serializeEnvelope(envelope));
208
+ }
209
+ }
@@ -0,0 +1,205 @@
1
+ import type WebSocket from "ws";
2
+ import type { Envelope } from "@linkshell/protocol";
3
+
4
+ export type SessionState = "active" | "host_disconnected" | "terminated";
5
+
6
+ export interface ConnectedDevice {
7
+ socket: WebSocket;
8
+ role: "host" | "client";
9
+ deviceId: string;
10
+ connectedAt: number;
11
+ }
12
+
13
+ export interface Session {
14
+ id: string;
15
+ state: SessionState;
16
+ host: ConnectedDevice | undefined;
17
+ clients: Map<string, ConnectedDevice>;
18
+ controllerId: string | undefined;
19
+ lastActivity: number;
20
+ createdAt: number;
21
+ outputBuffer: Envelope[];
22
+ hostDisconnectedAt: number | undefined;
23
+ // Metadata from host's session.connect
24
+ provider: string | undefined;
25
+ hostname: string | undefined;
26
+ }
27
+
28
+ const OUTPUT_BUFFER_CAPACITY = 200;
29
+ const HOST_RECONNECT_WINDOW = 60_000; // 60s
30
+ const CLEANUP_INTERVAL = 30_000;
31
+
32
+ export class SessionManager {
33
+ private sessions = new Map<string, Session>();
34
+ private cleanupTimer: ReturnType<typeof setInterval>;
35
+
36
+ constructor() {
37
+ this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
38
+ }
39
+
40
+ getOrCreate(sessionId: string): Session {
41
+ let session = this.sessions.get(sessionId);
42
+ if (!session) {
43
+ session = {
44
+ id: sessionId,
45
+ state: "active",
46
+ host: undefined,
47
+ clients: new Map(),
48
+ controllerId: undefined,
49
+ lastActivity: Date.now(),
50
+ createdAt: Date.now(),
51
+ outputBuffer: [],
52
+ hostDisconnectedAt: undefined,
53
+ provider: undefined,
54
+ hostname: undefined,
55
+ };
56
+ this.sessions.set(sessionId, session);
57
+ }
58
+ return session;
59
+ }
60
+
61
+ get(sessionId: string): Session | undefined {
62
+ return this.sessions.get(sessionId);
63
+ }
64
+
65
+ setHost(sessionId: string, device: ConnectedDevice): void {
66
+ const session = this.getOrCreate(sessionId);
67
+ session.host = device;
68
+ session.state = "active";
69
+ session.hostDisconnectedAt = undefined;
70
+ session.lastActivity = Date.now();
71
+ }
72
+
73
+ addClient(sessionId: string, device: ConnectedDevice): void {
74
+ const session = this.getOrCreate(sessionId);
75
+ session.clients.set(device.deviceId, device);
76
+ session.lastActivity = Date.now();
77
+ }
78
+
79
+ removeHost(
80
+ sessionId: string,
81
+ ): { clients: Map<string, ConnectedDevice> } | undefined {
82
+ const session = this.sessions.get(sessionId);
83
+ if (!session) return undefined;
84
+ session.host = undefined;
85
+ session.state = "host_disconnected";
86
+ session.hostDisconnectedAt = Date.now();
87
+ return { clients: session.clients };
88
+ }
89
+
90
+ removeClient(sessionId: string, deviceId: string): void {
91
+ const session = this.sessions.get(sessionId);
92
+ if (!session) return;
93
+ session.clients.delete(deviceId);
94
+ if (session.controllerId === deviceId) {
95
+ // Transfer control to next client or clear
96
+ const next = session.clients.keys().next();
97
+ session.controllerId = next.done ? undefined : next.value;
98
+ }
99
+ this.maybeDelete(sessionId);
100
+ }
101
+
102
+ bufferOutput(sessionId: string, envelope: Envelope): void {
103
+ const session = this.sessions.get(sessionId);
104
+ if (!session) return;
105
+ session.outputBuffer.push(envelope);
106
+ if (session.outputBuffer.length > OUTPUT_BUFFER_CAPACITY) {
107
+ session.outputBuffer.shift();
108
+ }
109
+ session.lastActivity = Date.now();
110
+ }
111
+
112
+ getReplayFrom(sessionId: string, afterSeq: number): Envelope[] {
113
+ const session = this.sessions.get(sessionId);
114
+ if (!session) return [];
115
+ return session.outputBuffer.filter(
116
+ (e) => e.seq !== undefined && e.seq > afterSeq,
117
+ );
118
+ }
119
+
120
+ claimControl(sessionId: string, deviceId: string): boolean {
121
+ const session = this.sessions.get(sessionId);
122
+ if (!session) return false;
123
+ if (session.controllerId && session.controllerId !== deviceId) {
124
+ return false;
125
+ }
126
+ session.controllerId = deviceId;
127
+ return true;
128
+ }
129
+
130
+ releaseControl(sessionId: string, deviceId: string): boolean {
131
+ const session = this.sessions.get(sessionId);
132
+ if (!session) return false;
133
+ if (session.controllerId !== deviceId) return false;
134
+ session.controllerId = undefined;
135
+ return true;
136
+ }
137
+
138
+ terminate(sessionId: string): void {
139
+ const session = this.sessions.get(sessionId);
140
+ if (!session) return;
141
+ session.state = "terminated";
142
+ }
143
+
144
+ listActive(): Session[] {
145
+ return [...this.sessions.values()].filter((s) => s.state !== "terminated");
146
+ }
147
+
148
+ getSummary(sessionId: string) {
149
+ const session = this.sessions.get(sessionId);
150
+ if (!session) return undefined;
151
+ return {
152
+ id: session.id,
153
+ state: session.state,
154
+ hasHost: !!session.host,
155
+ clientCount: session.clients.size,
156
+ controllerId: session.controllerId ?? null,
157
+ lastActivity: session.lastActivity,
158
+ createdAt: session.createdAt,
159
+ bufferSize: session.outputBuffer.length,
160
+ provider: session.provider ?? null,
161
+ hostname: session.hostname ?? null,
162
+ };
163
+ }
164
+
165
+ setMetadata(sessionId: string, provider?: string, hostname?: string): void {
166
+ const session = this.sessions.get(sessionId);
167
+ if (!session) return;
168
+ if (provider) session.provider = provider;
169
+ if (hostname) session.hostname = hostname;
170
+ }
171
+
172
+ private maybeDelete(sessionId: string): void {
173
+ const session = this.sessions.get(sessionId);
174
+ if (!session) return;
175
+ if (!session.host && session.clients.size === 0) {
176
+ this.sessions.delete(sessionId);
177
+ }
178
+ }
179
+
180
+ private cleanup(): void {
181
+ const now = Date.now();
182
+ for (const [id, session] of this.sessions) {
183
+ // Remove sessions where host disconnected and window expired
184
+ if (
185
+ session.state === "host_disconnected" &&
186
+ session.hostDisconnectedAt &&
187
+ now - session.hostDisconnectedAt > HOST_RECONNECT_WINDOW
188
+ ) {
189
+ session.state = "terminated";
190
+ }
191
+ // Clean up terminated sessions with no connections
192
+ if (
193
+ session.state === "terminated" &&
194
+ !session.host &&
195
+ session.clients.size === 0
196
+ ) {
197
+ this.sessions.delete(id);
198
+ }
199
+ }
200
+ }
201
+
202
+ destroy(): void {
203
+ clearInterval(this.cleanupTimer);
204
+ }
205
+ }