@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/pairings.ts
ADDED
|
@@ -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
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -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
|
+
}
|