deckide 3.5.15 → 3.5.17
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/dist/routes/decks.js +22 -1
- package/dist/routes/terminals.js +89 -26
- package/dist/server.js +1 -1
- package/dist/websocket.js +194 -30
- package/package.json +1 -1
- package/web/dist/assets/{index-WCMMFyXJ.css → index-Bw4TZ5S5.css} +1 -1
- package/web/dist/assets/index-_sb7NluC.js +122 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-SgvouX2D.js +0 -129
package/dist/routes/decks.js
CHANGED
|
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
3
|
import { createHttpError, handleError, readJson } from '../utils/error.js';
|
|
4
4
|
import { requireWorkspace } from './workspaces.js';
|
|
5
|
-
export function createDeckRouter(db, workspaces, decks) {
|
|
5
|
+
export function createDeckRouter(db, workspaces, decks, terminals) {
|
|
6
6
|
const router = new Hono();
|
|
7
7
|
const insertDeck = db.prepare('INSERT INTO decks (id, name, root, workspace_id, created_at) VALUES (?, ?, ?, ?, ?)');
|
|
8
8
|
function createDeck(name, workspaceId) {
|
|
@@ -76,12 +76,33 @@ export function createDeckRouter(db, workspaces, decks) {
|
|
|
76
76
|
});
|
|
77
77
|
const deleteDeckStmt = db.prepare('DELETE FROM decks WHERE id = ?');
|
|
78
78
|
const deleteTerminalsByDeckStmt = db.prepare('DELETE FROM terminals WHERE deck_id = ?');
|
|
79
|
+
function closeDeckTerminalSockets(sockets, reason) {
|
|
80
|
+
sockets.forEach((socket) => {
|
|
81
|
+
try {
|
|
82
|
+
socket.close(1000, reason);
|
|
83
|
+
}
|
|
84
|
+
catch { /* ignore */ }
|
|
85
|
+
});
|
|
86
|
+
sockets.clear();
|
|
87
|
+
}
|
|
79
88
|
router.delete('/:id', (c) => {
|
|
80
89
|
try {
|
|
81
90
|
const deckId = c.req.param('id');
|
|
82
91
|
if (!decks.has(deckId)) {
|
|
83
92
|
throw createHttpError('Deck not found', 404);
|
|
84
93
|
}
|
|
94
|
+
const deckTerminalIds = Array.from(terminals.values())
|
|
95
|
+
.filter((session) => session.deckId === deckId)
|
|
96
|
+
.map((session) => session.id);
|
|
97
|
+
deckTerminalIds.forEach((terminalId) => {
|
|
98
|
+
const session = terminals.get(terminalId);
|
|
99
|
+
if (!session)
|
|
100
|
+
return;
|
|
101
|
+
terminals.delete(terminalId);
|
|
102
|
+
session.resizeOwner = null;
|
|
103
|
+
closeDeckTerminalSockets(session.sockets, 'Deck deleted');
|
|
104
|
+
session.kill();
|
|
105
|
+
});
|
|
85
106
|
deleteTerminalsByDeckStmt.run(deckId);
|
|
86
107
|
deleteDeckStmt.run(deckId);
|
|
87
108
|
decks.delete(deckId);
|
package/dist/routes/terminals.js
CHANGED
|
@@ -5,43 +5,99 @@ import { TERMINAL_BUFFER_LIMIT } from '../config.js';
|
|
|
5
5
|
import { createHttpError, handleError, readJson } from '../utils/error.js';
|
|
6
6
|
import { getDefaultShell } from '../utils/shell.js';
|
|
7
7
|
import { saveTerminal, deleteTerminal as deleteTerminalFromDb } from '../utils/database.js';
|
|
8
|
-
|
|
9
|
-
const
|
|
8
|
+
const DEFAULT_TERMINAL_TITLE = 'ターミナル';
|
|
9
|
+
const MAX_SOCKET_BUFFERED_AMOUNT = 1024 * 1024;
|
|
10
10
|
export function createTerminalRouter(db, decks, terminals) {
|
|
11
11
|
const router = new Hono();
|
|
12
|
+
function toBuffer(data) {
|
|
13
|
+
return Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
14
|
+
}
|
|
12
15
|
function appendToTerminalBuffer(session, data) {
|
|
13
|
-
const
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
session.buffer = newBuffer.slice(excess);
|
|
17
|
-
session.bufferBase += excess;
|
|
16
|
+
const chunk = toBuffer(data);
|
|
17
|
+
if (chunk.length === 0) {
|
|
18
|
+
return;
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
if (chunk.length >= TERMINAL_BUFFER_LIMIT) {
|
|
21
|
+
const retainedChunk = Buffer.from(chunk.subarray(chunk.length - TERMINAL_BUFFER_LIMIT));
|
|
22
|
+
session.bufferBase += session.bufferLength + (chunk.length - TERMINAL_BUFFER_LIMIT);
|
|
23
|
+
session.bufferChunks = [retainedChunk];
|
|
24
|
+
session.bufferLength = retainedChunk.length;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
session.bufferChunks.push(Buffer.from(chunk));
|
|
28
|
+
session.bufferLength += chunk.length;
|
|
29
|
+
while (session.bufferLength > TERMINAL_BUFFER_LIMIT && session.bufferChunks.length > 0) {
|
|
30
|
+
const overflow = session.bufferLength - TERMINAL_BUFFER_LIMIT;
|
|
31
|
+
const firstChunk = session.bufferChunks[0];
|
|
32
|
+
if (firstChunk.length <= overflow) {
|
|
33
|
+
session.bufferChunks.shift();
|
|
34
|
+
session.bufferBase += firstChunk.length;
|
|
35
|
+
session.bufferLength -= firstChunk.length;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
session.bufferChunks[0] = Buffer.from(firstChunk.subarray(overflow));
|
|
39
|
+
session.bufferBase += overflow;
|
|
40
|
+
session.bufferLength -= overflow;
|
|
21
41
|
}
|
|
22
42
|
}
|
|
23
|
-
function
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
43
|
+
function getUniqueTerminalTitle(deckId, requestedTitle) {
|
|
44
|
+
const trimmedTitle = requestedTitle?.trim();
|
|
45
|
+
const baseTitle = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : null;
|
|
46
|
+
const existingTitles = new Set(Array.from(terminals.values())
|
|
47
|
+
.filter((session) => session.deckId === deckId)
|
|
48
|
+
.map((session) => session.title));
|
|
49
|
+
if (!baseTitle) {
|
|
50
|
+
let index = 1;
|
|
51
|
+
while (existingTitles.has(`${DEFAULT_TERMINAL_TITLE} ${index}`)) {
|
|
52
|
+
index++;
|
|
53
|
+
}
|
|
54
|
+
return `${DEFAULT_TERMINAL_TITLE} ${index}`;
|
|
55
|
+
}
|
|
56
|
+
if (!existingTitles.has(baseTitle)) {
|
|
57
|
+
return baseTitle;
|
|
58
|
+
}
|
|
59
|
+
let suffix = 2;
|
|
60
|
+
while (existingTitles.has(`${baseTitle} ${suffix}`)) {
|
|
61
|
+
suffix++;
|
|
62
|
+
}
|
|
63
|
+
return `${baseTitle} ${suffix}`;
|
|
28
64
|
}
|
|
29
65
|
function broadcastToSockets(session, data) {
|
|
66
|
+
const payload = toBuffer(data);
|
|
30
67
|
const deadSockets = new Set();
|
|
31
68
|
session.sockets.forEach((socket) => {
|
|
32
69
|
try {
|
|
33
|
-
if (socket.readyState
|
|
34
|
-
|
|
70
|
+
if (socket.readyState !== 1) {
|
|
71
|
+
deadSockets.add(socket);
|
|
72
|
+
return;
|
|
35
73
|
}
|
|
36
|
-
|
|
74
|
+
if (socket.bufferedAmount > MAX_SOCKET_BUFFERED_AMOUNT) {
|
|
75
|
+
try {
|
|
76
|
+
socket.close(1009, 'Terminal output overflow');
|
|
77
|
+
}
|
|
78
|
+
catch { /* ignore */ }
|
|
37
79
|
deadSockets.add(socket);
|
|
80
|
+
return;
|
|
38
81
|
}
|
|
82
|
+
socket.send(payload, { binary: true }, (error) => {
|
|
83
|
+
if (error) {
|
|
84
|
+
try {
|
|
85
|
+
socket.close(1011, 'Terminal stream error');
|
|
86
|
+
}
|
|
87
|
+
catch { /* ignore */ }
|
|
88
|
+
}
|
|
89
|
+
});
|
|
39
90
|
}
|
|
40
91
|
catch {
|
|
41
92
|
deadSockets.add(socket);
|
|
42
93
|
}
|
|
43
94
|
});
|
|
44
|
-
deadSockets.forEach((s) =>
|
|
95
|
+
deadSockets.forEach((s) => {
|
|
96
|
+
session.sockets.delete(s);
|
|
97
|
+
if (session.resizeOwner === s) {
|
|
98
|
+
session.resizeOwner = null;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
45
101
|
}
|
|
46
102
|
function handleTerminalExit(id) {
|
|
47
103
|
const session = terminals.get(id);
|
|
@@ -57,6 +113,7 @@ export function createTerminalRouter(db, decks, terminals) {
|
|
|
57
113
|
catch { /* ignore */ }
|
|
58
114
|
});
|
|
59
115
|
session.sockets.clear();
|
|
116
|
+
session.resizeOwner = null;
|
|
60
117
|
}
|
|
61
118
|
function createTerminalSession(deck, title, command) {
|
|
62
119
|
const id = crypto.randomUUID();
|
|
@@ -106,11 +163,11 @@ export function createTerminalRouter(db, decks, terminals) {
|
|
|
106
163
|
cols: 120,
|
|
107
164
|
rows: 32,
|
|
108
165
|
env,
|
|
109
|
-
encoding:
|
|
166
|
+
encoding: null,
|
|
110
167
|
...(isWindows ? { useConpty: true } : {}),
|
|
111
168
|
});
|
|
112
169
|
console.log(`[TERMINAL] Created terminal ${id}: shell=${shell}, cwd=${deck.root}, pid=${term.pid}`);
|
|
113
|
-
const resolvedTitle =
|
|
170
|
+
const resolvedTitle = getUniqueTerminalTitle(deck.id, title);
|
|
114
171
|
const createdAt = new Date().toISOString();
|
|
115
172
|
const session = {
|
|
116
173
|
id,
|
|
@@ -119,13 +176,18 @@ export function createTerminalRouter(db, decks, terminals) {
|
|
|
119
176
|
command: command || null,
|
|
120
177
|
createdAt,
|
|
121
178
|
sockets: new Set(),
|
|
122
|
-
|
|
179
|
+
resizeOwner: null,
|
|
180
|
+
bufferChunks: [],
|
|
181
|
+
bufferLength: 0,
|
|
123
182
|
bufferBase: 0,
|
|
124
183
|
lastActive: Date.now(),
|
|
125
|
-
write: (data) => {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
184
|
+
write: (data) => {
|
|
185
|
+
const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
186
|
+
try {
|
|
187
|
+
term.write(payload);
|
|
188
|
+
}
|
|
189
|
+
catch { /* terminal may be dying */ }
|
|
190
|
+
},
|
|
129
191
|
resize: (cols, rows) => { try {
|
|
130
192
|
term.resize(cols, rows);
|
|
131
193
|
}
|
|
@@ -136,7 +198,7 @@ export function createTerminalRouter(db, decks, terminals) {
|
|
|
136
198
|
catch { /* already dead */ } },
|
|
137
199
|
};
|
|
138
200
|
// Wire up PTY output → buffer + WebSocket broadcast
|
|
139
|
-
term.
|
|
201
|
+
term.on('data', (data) => {
|
|
140
202
|
appendToTerminalBuffer(session, data);
|
|
141
203
|
session.lastActive = Date.now();
|
|
142
204
|
broadcastToSockets(session, data);
|
|
@@ -193,6 +255,7 @@ export function createTerminalRouter(db, decks, terminals) {
|
|
|
193
255
|
catch { /* ignore */ }
|
|
194
256
|
});
|
|
195
257
|
session.sockets.clear();
|
|
258
|
+
session.resizeOwner = null;
|
|
196
259
|
session.kill();
|
|
197
260
|
return c.body(null, 204);
|
|
198
261
|
}
|
package/dist/server.js
CHANGED
|
@@ -65,7 +65,7 @@ export async function createServer() {
|
|
|
65
65
|
// Mount routers
|
|
66
66
|
app.route('/api/settings', createSettingsRouter());
|
|
67
67
|
app.route('/api/workspaces', createWorkspaceRouter(db, workspaces, workspacePathIndex));
|
|
68
|
-
app.route('/api/decks', createDeckRouter(db, workspaces, decks));
|
|
68
|
+
app.route('/api/decks', createDeckRouter(db, workspaces, decks, terminals));
|
|
69
69
|
const terminalRouter = createTerminalRouter(db, decks, terminals);
|
|
70
70
|
app.route('/api/terminals', terminalRouter);
|
|
71
71
|
app.route('/api/git', createGitRouter(workspaces));
|
package/dist/websocket.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
-
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
3
3
|
import { PORT, TRUST_PROXY, CORS_ORIGIN, NODE_ENV } from './config.js';
|
|
4
4
|
import { logSecurityEvent } from './middleware/security.js';
|
|
5
5
|
import { verifyWebSocketAuth } from './middleware/auth.js';
|
|
6
6
|
const MIN_TERMINAL_SIZE = 1;
|
|
7
7
|
const MAX_TERMINAL_SIZE = 500;
|
|
8
8
|
const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB max message size
|
|
9
|
+
const MAX_SOCKET_BUFFERED_AMOUNT = 1024 * 1024;
|
|
10
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
9
11
|
// Configurable connection limit per IP (default: 1000)
|
|
10
12
|
let maxConnectionsPerIP = 1000;
|
|
11
13
|
// Track connections per IP
|
|
@@ -75,6 +77,8 @@ function untrackConnection(ip, socket) {
|
|
|
75
77
|
}
|
|
76
78
|
}
|
|
77
79
|
function validateTerminalSize(value) {
|
|
80
|
+
if (value == null)
|
|
81
|
+
return MIN_TERMINAL_SIZE;
|
|
78
82
|
const intValue = Math.floor(value);
|
|
79
83
|
if (!Number.isFinite(intValue) || intValue < MIN_TERMINAL_SIZE) {
|
|
80
84
|
return MIN_TERMINAL_SIZE;
|
|
@@ -84,8 +88,113 @@ function validateTerminalSize(value) {
|
|
|
84
88
|
}
|
|
85
89
|
return intValue;
|
|
86
90
|
}
|
|
91
|
+
function rawDataByteLength(data) {
|
|
92
|
+
if (typeof data === 'string') {
|
|
93
|
+
return Buffer.byteLength(data, 'utf8');
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(data)) {
|
|
96
|
+
return data.reduce((total, chunk) => total + chunk.length, 0);
|
|
97
|
+
}
|
|
98
|
+
if (data instanceof ArrayBuffer) {
|
|
99
|
+
return data.byteLength;
|
|
100
|
+
}
|
|
101
|
+
return data.length;
|
|
102
|
+
}
|
|
103
|
+
function rawDataToBuffer(data) {
|
|
104
|
+
if (typeof data === 'string') {
|
|
105
|
+
return Buffer.from(data, 'utf8');
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(data)) {
|
|
108
|
+
return Buffer.concat(data);
|
|
109
|
+
}
|
|
110
|
+
if (data instanceof ArrayBuffer) {
|
|
111
|
+
return Buffer.from(data);
|
|
112
|
+
}
|
|
113
|
+
// data is Buffer
|
|
114
|
+
return Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
115
|
+
}
|
|
116
|
+
function canSendToSocket(socket) {
|
|
117
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
if (socket.bufferedAmount > MAX_SOCKET_BUFFERED_AMOUNT) {
|
|
121
|
+
try {
|
|
122
|
+
socket.close(1009, 'Terminal output overflow');
|
|
123
|
+
}
|
|
124
|
+
catch { /* ignore */ }
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
function sendControl(socket, message) {
|
|
130
|
+
if (!canSendToSocket(socket)) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
socket.send(JSON.stringify(message));
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
try {
|
|
139
|
+
socket.close(1011, 'Terminal control send failed');
|
|
140
|
+
}
|
|
141
|
+
catch { /* ignore */ }
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function readBufferedRange(session, startOffset, endOffset) {
|
|
146
|
+
const relativeStart = Math.max(0, startOffset - session.bufferBase);
|
|
147
|
+
const relativeEnd = Math.max(relativeStart, Math.min(session.bufferLength, endOffset - session.bufferBase));
|
|
148
|
+
const totalLength = relativeEnd - relativeStart;
|
|
149
|
+
if (totalLength <= 0 || session.bufferChunks.length === 0) {
|
|
150
|
+
return Buffer.alloc(0);
|
|
151
|
+
}
|
|
152
|
+
if (session.bufferChunks.length === 1) {
|
|
153
|
+
return session.bufferChunks[0].subarray(relativeStart, relativeEnd);
|
|
154
|
+
}
|
|
155
|
+
const slices = [];
|
|
156
|
+
let traversed = 0;
|
|
157
|
+
for (const chunk of session.bufferChunks) {
|
|
158
|
+
const chunkStart = traversed;
|
|
159
|
+
const chunkEnd = traversed + chunk.length;
|
|
160
|
+
traversed = chunkEnd;
|
|
161
|
+
if (chunkEnd <= relativeStart) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (chunkStart >= relativeEnd) {
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
const startInChunk = Math.max(0, relativeStart - chunkStart);
|
|
168
|
+
const endInChunk = Math.min(chunk.length, relativeEnd - chunkStart);
|
|
169
|
+
slices.push(chunk.subarray(startInChunk, endInChunk));
|
|
170
|
+
}
|
|
171
|
+
return slices.length === 1 ? slices[0] : Buffer.concat(slices, totalLength);
|
|
172
|
+
}
|
|
87
173
|
export function setupWebSocketServer(server, terminals) {
|
|
88
|
-
const wss = new WebSocketServer({ server });
|
|
174
|
+
const wss = new WebSocketServer({ server, maxPayload: MAX_MESSAGE_SIZE });
|
|
175
|
+
const heartbeatState = new WeakMap();
|
|
176
|
+
const heartbeatInterval = setInterval(() => {
|
|
177
|
+
for (const socket of wss.clients) {
|
|
178
|
+
if (heartbeatState.get(socket) === false) {
|
|
179
|
+
try {
|
|
180
|
+
socket.terminate();
|
|
181
|
+
}
|
|
182
|
+
catch { /* ignore */ }
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
heartbeatState.set(socket, false);
|
|
186
|
+
try {
|
|
187
|
+
socket.ping();
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
try {
|
|
191
|
+
socket.terminate();
|
|
192
|
+
}
|
|
193
|
+
catch { /* ignore */ }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
197
|
+
heartbeatInterval.unref?.();
|
|
89
198
|
const WS_ALLOWED_ORIGINS = new Set([
|
|
90
199
|
`http://localhost:${PORT}`,
|
|
91
200
|
]);
|
|
@@ -101,6 +210,7 @@ export function setupWebSocketServer(server, terminals) {
|
|
|
101
210
|
wss.on('connection', (socket, req) => {
|
|
102
211
|
const socketId = crypto.randomUUID();
|
|
103
212
|
const clientIP = getClientIP(req);
|
|
213
|
+
heartbeatState.set(socket, true);
|
|
104
214
|
// Validate Origin header to prevent Cross-Site WebSocket Hijacking
|
|
105
215
|
// Skip check if CORS_ORIGIN is '*' or unset in development mode
|
|
106
216
|
const skipOriginCheck = CORS_ORIGIN === '*' || (!CORS_ORIGIN && NODE_ENV !== 'production');
|
|
@@ -120,6 +230,9 @@ export function setupWebSocketServer(server, terminals) {
|
|
|
120
230
|
// Socket might already be closed
|
|
121
231
|
}
|
|
122
232
|
});
|
|
233
|
+
socket.on('pong', () => {
|
|
234
|
+
heartbeatState.set(socket, true);
|
|
235
|
+
});
|
|
123
236
|
// Check connection limit per IP
|
|
124
237
|
if (!trackConnection(clientIP, socket)) {
|
|
125
238
|
logSecurityEvent('WS_CONNECTION_LIMIT_EXCEEDED', { ip: clientIP });
|
|
@@ -146,67 +259,111 @@ export function setupWebSocketServer(server, terminals) {
|
|
|
146
259
|
socket.close(1000, 'Terminal not found');
|
|
147
260
|
return;
|
|
148
261
|
}
|
|
262
|
+
const hadNoSockets = session.sockets.size === 0;
|
|
149
263
|
session.sockets.add(socket);
|
|
264
|
+
if (hadNoSockets && !session.resizeOwner) {
|
|
265
|
+
session.resizeOwner = socket;
|
|
266
|
+
}
|
|
150
267
|
session.lastActive = Date.now();
|
|
151
268
|
// Send buffer content if available
|
|
152
|
-
// bufferOffset: absolute
|
|
153
|
-
// bufferBase: absolute position of buffer[0] (
|
|
269
|
+
// bufferOffset: absolute byte count the client already processed
|
|
270
|
+
// bufferBase: absolute byte position of buffer[0] (bytes dropped from start)
|
|
154
271
|
const offsetParam = url.searchParams.get('bufferOffset');
|
|
155
272
|
const clientOffset = offsetParam ? Math.max(0, parseInt(offsetParam, 10) || 0) : 0;
|
|
156
|
-
|
|
273
|
+
const bufferStart = session.bufferBase;
|
|
274
|
+
const bufferEnd = session.bufferBase + session.bufferLength;
|
|
275
|
+
let replayStartOffset = clientOffset;
|
|
276
|
+
let resetTerminal = false;
|
|
277
|
+
if (clientOffset <= bufferStart) {
|
|
278
|
+
replayStartOffset = bufferStart;
|
|
279
|
+
resetTerminal = session.bufferLength > 0;
|
|
280
|
+
}
|
|
281
|
+
else if (clientOffset >= bufferEnd) {
|
|
282
|
+
replayStartOffset = bufferEnd;
|
|
283
|
+
}
|
|
284
|
+
if (!sendControl(socket, { type: 'sync', offsetBase: replayStartOffset, reset: resetTerminal })) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (session.bufferLength > 0) {
|
|
157
288
|
try {
|
|
158
|
-
|
|
159
|
-
const bufferEnd = session.bufferBase + session.buffer.length;
|
|
160
|
-
let bufferToSend;
|
|
289
|
+
let bufferToSend = Buffer.alloc(0);
|
|
161
290
|
if (clientOffset <= bufferStart) {
|
|
162
291
|
// Client's last position is before (or at) what we have — send everything
|
|
163
|
-
bufferToSend = session
|
|
292
|
+
bufferToSend = readBufferedRange(session, bufferStart, bufferEnd);
|
|
164
293
|
}
|
|
165
294
|
else if (clientOffset >= bufferEnd) {
|
|
166
295
|
// Client is fully up to date
|
|
167
|
-
bufferToSend =
|
|
296
|
+
bufferToSend = Buffer.alloc(0);
|
|
168
297
|
}
|
|
169
298
|
else {
|
|
170
299
|
// Send only the delta
|
|
171
|
-
bufferToSend = session
|
|
300
|
+
bufferToSend = readBufferedRange(session, clientOffset, bufferEnd);
|
|
172
301
|
}
|
|
173
|
-
if (bufferToSend) {
|
|
174
|
-
socket.send(bufferToSend);
|
|
302
|
+
if (bufferToSend.length > 0 && canSendToSocket(socket)) {
|
|
303
|
+
socket.send(bufferToSend, { binary: true });
|
|
175
304
|
}
|
|
176
305
|
}
|
|
177
306
|
catch (error) {
|
|
178
307
|
console.error(`Failed to send buffer to socket ${socketId}:`, error);
|
|
179
308
|
}
|
|
180
309
|
}
|
|
181
|
-
socket
|
|
310
|
+
sendControl(socket, { type: 'ready' });
|
|
311
|
+
socket.on('message', (data, isBinary) => {
|
|
182
312
|
try {
|
|
183
|
-
|
|
184
|
-
const message = data.toString('utf8');
|
|
185
|
-
// Check message size
|
|
186
|
-
const messageSize = Buffer.byteLength(message, 'utf8');
|
|
313
|
+
const messageSize = rawDataByteLength(data);
|
|
187
314
|
if (messageSize > MAX_MESSAGE_SIZE) {
|
|
188
315
|
logSecurityEvent('WS_MESSAGE_TOO_LARGE', { ip: clientIP, size: messageSize });
|
|
189
|
-
socket.
|
|
316
|
+
socket.close(1009, 'Message too large');
|
|
190
317
|
return;
|
|
191
318
|
}
|
|
192
319
|
session.lastActive = Date.now();
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
320
|
+
// Text frames carry JSON control messages (claim, resize).
|
|
321
|
+
// Note: the ws library delivers text frames as Buffer by default,
|
|
322
|
+
// so we use the isBinary flag rather than typeof === 'string'.
|
|
323
|
+
if (!isBinary) {
|
|
324
|
+
const text = typeof data === 'string'
|
|
325
|
+
? data
|
|
326
|
+
: Array.isArray(data)
|
|
327
|
+
? Buffer.concat(data).toString('utf8')
|
|
328
|
+
: Buffer.from(data).toString('utf8');
|
|
329
|
+
let control = null;
|
|
199
330
|
try {
|
|
200
|
-
|
|
331
|
+
control = JSON.parse(text);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
control = null;
|
|
201
335
|
}
|
|
202
|
-
|
|
203
|
-
|
|
336
|
+
if (control?.type === 'claim') {
|
|
337
|
+
session.resizeOwner = socket;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (control?.type === 'resize') {
|
|
341
|
+
if (session.resizeOwner && session.resizeOwner !== socket) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
session.resizeOwner = socket;
|
|
345
|
+
const cols = validateTerminalSize(control.cols);
|
|
346
|
+
const rows = validateTerminalSize(control.rows);
|
|
347
|
+
try {
|
|
348
|
+
session.resize(cols, rows);
|
|
349
|
+
}
|
|
350
|
+
catch (resizeError) {
|
|
351
|
+
console.error(`Failed to resize terminal ${id}:`, resizeError);
|
|
352
|
+
}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
// Unknown text frame — still forward to terminal for compatibility
|
|
356
|
+
try {
|
|
357
|
+
session.write(Buffer.from(text, 'utf8'));
|
|
358
|
+
}
|
|
359
|
+
catch (writeError) {
|
|
360
|
+
console.error(`Failed to write text input to terminal ${id}:`, writeError);
|
|
204
361
|
}
|
|
205
362
|
return;
|
|
206
363
|
}
|
|
364
|
+
// Binary frames carry terminal input (keystrokes, paste, etc.)
|
|
207
365
|
try {
|
|
208
|
-
|
|
209
|
-
session.write(message);
|
|
366
|
+
session.write(rawDataToBuffer(data));
|
|
210
367
|
}
|
|
211
368
|
catch (writeError) {
|
|
212
369
|
console.error(`Failed to write to terminal ${id}:`, writeError);
|
|
@@ -218,12 +375,19 @@ export function setupWebSocketServer(server, terminals) {
|
|
|
218
375
|
});
|
|
219
376
|
socket.on('close', () => {
|
|
220
377
|
session.sockets.delete(socket);
|
|
378
|
+
if (session.resizeOwner === socket) {
|
|
379
|
+
session.resizeOwner = null;
|
|
380
|
+
}
|
|
221
381
|
session.lastActive = Date.now();
|
|
382
|
+
heartbeatState.delete(socket);
|
|
222
383
|
untrackConnection(clientIP, socket);
|
|
223
384
|
});
|
|
224
385
|
});
|
|
225
386
|
wss.on('error', (error) => {
|
|
226
387
|
console.error('WebSocket server error:', error);
|
|
227
388
|
});
|
|
389
|
+
wss.on('close', () => {
|
|
390
|
+
clearInterval(heartbeatInterval);
|
|
391
|
+
});
|
|
228
392
|
return wss;
|
|
229
393
|
}
|