deckide 3.5.24 → 3.5.26
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/terminals.js
CHANGED
|
@@ -5,7 +5,7 @@ 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
|
-
import { alignToUtf8Start } from '../utils/utf8.js';
|
|
8
|
+
import { alignToUtf8Start, skipPartialEscapeSequence } from '../utils/utf8.js';
|
|
9
9
|
const DEFAULT_TERMINAL_TITLE = 'ターミナル';
|
|
10
10
|
const MAX_SOCKET_BUFFERED_AMOUNT = 1024 * 1024;
|
|
11
11
|
export function createTerminalRouter(db, decks, terminals) {
|
|
@@ -21,15 +21,22 @@ export function createTerminalRouter(db, decks, terminals) {
|
|
|
21
21
|
if (chunk.length >= TERMINAL_BUFFER_LIMIT) {
|
|
22
22
|
let cutPos = chunk.length - TERMINAL_BUFFER_LIMIT;
|
|
23
23
|
cutPos = alignToUtf8Start(chunk, cutPos);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
let retained = Buffer.from(chunk.subarray(cutPos));
|
|
25
|
+
// Skip partial escape sequence at the new start
|
|
26
|
+
const escSkip = skipPartialEscapeSequence(retained, 0);
|
|
27
|
+
if (escSkip > 0) {
|
|
28
|
+
retained = Buffer.from(retained.subarray(escSkip));
|
|
29
|
+
}
|
|
30
|
+
session.bufferBase += session.bufferLength + cutPos + escSkip;
|
|
31
|
+
session.bufferChunks = [retained];
|
|
32
|
+
session.bufferLength = retained.length;
|
|
28
33
|
return;
|
|
29
34
|
}
|
|
30
35
|
session.bufferChunks.push(Buffer.from(chunk));
|
|
31
36
|
session.bufferLength += chunk.length;
|
|
37
|
+
let trimmed = false;
|
|
32
38
|
while (session.bufferLength > TERMINAL_BUFFER_LIMIT && session.bufferChunks.length > 0) {
|
|
39
|
+
trimmed = true;
|
|
33
40
|
const overflow = session.bufferLength - TERMINAL_BUFFER_LIMIT;
|
|
34
41
|
const firstChunk = session.bufferChunks[0];
|
|
35
42
|
if (firstChunk.length <= overflow) {
|
|
@@ -43,9 +50,9 @@ export function createTerminalRouter(db, decks, terminals) {
|
|
|
43
50
|
session.bufferBase += cutPos;
|
|
44
51
|
session.bufferLength -= cutPos;
|
|
45
52
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
if (trimmed && session.bufferChunks.length > 0) {
|
|
54
|
+
// After removing whole chunks, the new first chunk may start with
|
|
55
|
+
// orphaned UTF-8 continuation bytes from a character that spanned chunks.
|
|
49
56
|
const first = session.bufferChunks[0];
|
|
50
57
|
const skip = alignToUtf8Start(first, 0);
|
|
51
58
|
if (skip > 0) {
|
|
@@ -53,6 +60,16 @@ export function createTerminalRouter(db, decks, terminals) {
|
|
|
53
60
|
session.bufferBase += skip;
|
|
54
61
|
session.bufferLength -= skip;
|
|
55
62
|
}
|
|
63
|
+
// Also skip past any partial ANSI CSI escape sequence at the start.
|
|
64
|
+
// Without this, orphaned parameter bytes like "100;50m" are rendered
|
|
65
|
+
// as literal text and shift all cursor positions.
|
|
66
|
+
const cur = session.bufferChunks[0];
|
|
67
|
+
const escSkip = skipPartialEscapeSequence(cur, 0);
|
|
68
|
+
if (escSkip > 0) {
|
|
69
|
+
session.bufferChunks[0] = Buffer.from(cur.subarray(escSkip));
|
|
70
|
+
session.bufferBase += escSkip;
|
|
71
|
+
session.bufferLength -= escSkip;
|
|
72
|
+
}
|
|
56
73
|
}
|
|
57
74
|
}
|
|
58
75
|
function getUniqueTerminalTitle(deckId, requestedTitle) {
|
package/dist/utils/utf8.js
CHANGED
|
@@ -53,3 +53,157 @@ export function alignToUtf8End(buf, offset) {
|
|
|
53
53
|
// The character is incomplete — exclude it
|
|
54
54
|
return pos;
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Skip past a partial ANSI escape sequence at the start of a buffer.
|
|
58
|
+
*
|
|
59
|
+
* When a terminal buffer is trimmed from the front, the cut point may land
|
|
60
|
+
* inside an escape sequence. The leading ESC byte (and possibly the type
|
|
61
|
+
* indicator) is discarded, leaving orphaned payload bytes that xterm.js
|
|
62
|
+
* would render as literal text, shifting all subsequent cursor positions.
|
|
63
|
+
*
|
|
64
|
+
* Handled sequence types:
|
|
65
|
+
*
|
|
66
|
+
* CSI (ESC [) — e.g. "\x1b[38;2;100;50m"
|
|
67
|
+
* Detected as: '[' + params/intermediates + final byte
|
|
68
|
+
* or: bare params with ';' + final byte (ESC and '[' both trimmed)
|
|
69
|
+
*
|
|
70
|
+
* OSC (ESC ]) — e.g. "\x1b]0;title\x07"
|
|
71
|
+
* Detected as: ']' + text + BEL/ST terminator
|
|
72
|
+
*
|
|
73
|
+
* DCS (ESC P) — e.g. "\x1bP1$r...\x1b\\"
|
|
74
|
+
* Detected as: 'P' + text + ST terminator
|
|
75
|
+
*
|
|
76
|
+
* APC (ESC _) — e.g. "\x1b_...\x1b\\"
|
|
77
|
+
* Detected as: '_' + text + ST terminator
|
|
78
|
+
*
|
|
79
|
+
* Returns the number of bytes to skip (0 if no partial sequence detected).
|
|
80
|
+
*/
|
|
81
|
+
export function skipPartialEscapeSequence(buf, offset) {
|
|
82
|
+
if (offset >= buf.length)
|
|
83
|
+
return 0;
|
|
84
|
+
const b = buf[offset];
|
|
85
|
+
// ── CSI: starts with '[' (ESC was trimmed) ──
|
|
86
|
+
if (b === 0x5B /* '[' */) {
|
|
87
|
+
return skipPartialCSI(buf, offset);
|
|
88
|
+
}
|
|
89
|
+
// ── OSC: starts with ']' (ESC was trimmed) ──
|
|
90
|
+
if (b === 0x5D /* ']' */) {
|
|
91
|
+
return skipStringSequence(buf, offset);
|
|
92
|
+
}
|
|
93
|
+
// ── DCS: starts with 'P' (ESC was trimmed) ──
|
|
94
|
+
// Only treat as DCS if followed by a parameter byte, '$', or printable
|
|
95
|
+
// control sequence byte — avoids false positive on words like "Path".
|
|
96
|
+
if (b === 0x50 /* 'P' */ && offset + 1 < buf.length) {
|
|
97
|
+
const next = buf[offset + 1];
|
|
98
|
+
if ((next >= 0x30 && next <= 0x3F) || next === 0x24 /* '$' */) {
|
|
99
|
+
return skipStringSequence(buf, offset);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// ── APC: starts with '_' (ESC was trimmed) ──
|
|
103
|
+
if (b === 0x5F /* '_' */) {
|
|
104
|
+
return skipStringSequence(buf, offset);
|
|
105
|
+
}
|
|
106
|
+
// ── Bare CSI params (both ESC and '[' trimmed) ──
|
|
107
|
+
if (b >= 0x30 && b <= 0x3F) {
|
|
108
|
+
return skipBareCSIParams(buf, offset);
|
|
109
|
+
}
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Skip a partial CSI sequence starting with '['.
|
|
114
|
+
* Pattern: '[' params intermediates final
|
|
115
|
+
*/
|
|
116
|
+
function skipPartialCSI(buf, offset) {
|
|
117
|
+
const limit = Math.min(buf.length, offset + 128);
|
|
118
|
+
if (offset + 1 >= limit)
|
|
119
|
+
return 0;
|
|
120
|
+
const next = buf[offset + 1];
|
|
121
|
+
// Only treat as CSI if the next byte is a parameter (0x30-0x3F) or
|
|
122
|
+
// intermediate (0x20-0x2F) — avoids false positives like "[user@host"
|
|
123
|
+
if (next < 0x20 || (next > 0x3F && next < 0x40))
|
|
124
|
+
return 0;
|
|
125
|
+
// If next is already a final byte (letter), it could be "[H" (cursor home)
|
|
126
|
+
// or "[hello" — skip only the 2-byte "[H" style if the byte after final
|
|
127
|
+
// is a control char or ESC (strong signal it was a real sequence).
|
|
128
|
+
if (next >= 0x40 && next <= 0x7E) {
|
|
129
|
+
if (offset + 2 < buf.length) {
|
|
130
|
+
const after = buf[offset + 2];
|
|
131
|
+
if (after === 0x1B || after < 0x20)
|
|
132
|
+
return 2;
|
|
133
|
+
}
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
let pos = offset + 1;
|
|
137
|
+
while (pos < limit) {
|
|
138
|
+
const c = buf[pos];
|
|
139
|
+
if ((c >= 0x30 && c <= 0x3F) || (c >= 0x20 && c <= 0x2F)) {
|
|
140
|
+
pos++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (c >= 0x40 && c <= 0x7E) {
|
|
144
|
+
return (pos + 1) - offset;
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Skip bare CSI parameter bytes (both ESC and '[' were trimmed).
|
|
152
|
+
* Pattern: digits/';' (with at least one ';') + final byte
|
|
153
|
+
*/
|
|
154
|
+
function skipBareCSIParams(buf, offset) {
|
|
155
|
+
const limit = Math.min(buf.length, offset + 128);
|
|
156
|
+
let pos = offset;
|
|
157
|
+
let hasSemicolon = false;
|
|
158
|
+
while (pos < limit) {
|
|
159
|
+
const c = buf[pos];
|
|
160
|
+
if (c >= 0x30 && c <= 0x3F) {
|
|
161
|
+
if (c === 0x3B)
|
|
162
|
+
hasSemicolon = true;
|
|
163
|
+
pos++;
|
|
164
|
+
}
|
|
165
|
+
else if (c >= 0x20 && c <= 0x2F) {
|
|
166
|
+
pos++;
|
|
167
|
+
}
|
|
168
|
+
else if (c >= 0x40 && c <= 0x7E) {
|
|
169
|
+
// Only skip if we saw ';' — avoids false positives like "5m"
|
|
170
|
+
return hasSemicolon ? (pos + 1) - offset : 0;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Skip a string-type escape sequence (OSC / DCS / APC) that starts with
|
|
180
|
+
* the type indicator byte (']', 'P', or '_') — the leading ESC was trimmed.
|
|
181
|
+
*
|
|
182
|
+
* These sequences are terminated by:
|
|
183
|
+
* - BEL (0x07) — common for OSC
|
|
184
|
+
* - ST (ESC \ = 0x1B 0x5C) — standard for all
|
|
185
|
+
*
|
|
186
|
+
* We scan up to 4 KB for the terminator; if not found we skip nothing
|
|
187
|
+
* (the partial sequence might span far beyond what we want to discard).
|
|
188
|
+
*/
|
|
189
|
+
function skipStringSequence(buf, offset) {
|
|
190
|
+
const limit = Math.min(buf.length, offset + 4096);
|
|
191
|
+
let pos = offset + 1; // skip the type indicator byte
|
|
192
|
+
while (pos < limit) {
|
|
193
|
+
const c = buf[pos];
|
|
194
|
+
if (c === 0x07) {
|
|
195
|
+
// BEL terminator
|
|
196
|
+
return (pos + 1) - offset;
|
|
197
|
+
}
|
|
198
|
+
if (c === 0x1B && pos + 1 < buf.length && buf[pos + 1] === 0x5C) {
|
|
199
|
+
// ST terminator (ESC \)
|
|
200
|
+
return (pos + 2) - offset;
|
|
201
|
+
}
|
|
202
|
+
// If we hit another ESC that's NOT followed by '\', it's a new sequence
|
|
203
|
+
if (c === 0x1B) {
|
|
204
|
+
return pos - offset;
|
|
205
|
+
}
|
|
206
|
+
pos++;
|
|
207
|
+
}
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
package/dist/websocket.js
CHANGED
|
@@ -149,11 +149,11 @@ function readBufferedRange(session, startOffset, endOffset) {
|
|
|
149
149
|
if (relativeEnd <= relativeStart || session.bufferChunks.length === 0) {
|
|
150
150
|
return Buffer.alloc(0);
|
|
151
151
|
}
|
|
152
|
-
// Materialize the raw range
|
|
153
|
-
//
|
|
152
|
+
// Materialize the raw range into a contiguous copy so the caller
|
|
153
|
+
// is not affected if buffer chunks are later trimmed or reassigned.
|
|
154
154
|
let raw;
|
|
155
155
|
if (session.bufferChunks.length === 1) {
|
|
156
|
-
raw = session.bufferChunks[0].subarray(relativeStart, relativeEnd);
|
|
156
|
+
raw = Buffer.from(session.bufferChunks[0].subarray(relativeStart, relativeEnd));
|
|
157
157
|
}
|
|
158
158
|
else {
|
|
159
159
|
const slices = [];
|
|
@@ -172,7 +172,7 @@ function readBufferedRange(session, startOffset, endOffset) {
|
|
|
172
172
|
const endInChunk = Math.min(chunk.length, relativeEnd - chunkStart);
|
|
173
173
|
slices.push(chunk.subarray(startInChunk, endInChunk));
|
|
174
174
|
}
|
|
175
|
-
raw = slices
|
|
175
|
+
raw = Buffer.concat(slices); // concat always creates a new Buffer
|
|
176
176
|
}
|
|
177
177
|
// Align start: skip orphaned continuation bytes
|
|
178
178
|
const alignedStart = alignToUtf8Start(raw, 0);
|
|
@@ -181,7 +181,7 @@ function readBufferedRange(session, startOffset, endOffset) {
|
|
|
181
181
|
if (alignedStart === 0 && alignedEnd === raw.length) {
|
|
182
182
|
return raw;
|
|
183
183
|
}
|
|
184
|
-
return raw.subarray(alignedStart, alignedEnd);
|
|
184
|
+
return Buffer.from(raw.subarray(alignedStart, alignedEnd));
|
|
185
185
|
}
|
|
186
186
|
export function setupWebSocketServer(server, terminals) {
|
|
187
187
|
const wss = new WebSocketServer({ server, maxPayload: MAX_MESSAGE_SIZE });
|