fix: decode UTF-8 WebSocket output before writing to xterm.js
xterm.js write(Uint8Array) treats each byte as Latin-1, not UTF-8. Multi-byte UTF-8 characters like box-drawing ─ (U+2500, bytes E2 94 80) were rendered as â (Latin-1 interpretation of 0xE2). Fix: decode with TextDecoder before _term.write(), matching ttyd's official client pattern. Add module-level _decoder (alongside existing _encoder) and use _decoder.decode(payload) in the type 0x30 message handler.
This commit is contained in:
@@ -14,6 +14,11 @@ let _searchAddon = null;
|
|||||||
// ─── Module-level encoding helpers ──────────────────────────────────────────
|
// ─── Module-level encoding helpers ──────────────────────────────────────────
|
||||||
// Hoisted here so the clipboard key handler (in openTerminal) can also use them.
|
// Hoisted here so the clipboard key handler (in openTerminal) can also use them.
|
||||||
const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
||||||
|
// TextDecoder: used to decode UTF-8 bytes received from ttyd before writing to xterm.js.
|
||||||
|
// xterm.js write(Uint8Array) treats each byte as Latin-1, not UTF-8 — multi-byte characters
|
||||||
|
// like ─ (U+2500, bytes E2 94 80) render as â (Latin-1 0xE2) without decoding first.
|
||||||
|
// Matches ttyd's official client pattern: textDecoder.decode(payload) → _term.write(string).
|
||||||
|
const _decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
|
||||||
|
|
||||||
function _encodePayload(typeChar, str) {
|
function _encodePayload(typeChar, str) {
|
||||||
// Returns Uint8Array: [typeCharCode, ...utf8bytes]
|
// Returns Uint8Array: [typeCharCode, ...utf8bytes]
|
||||||
@@ -131,7 +136,8 @@ function connectWebSocket(name, remoteId) {
|
|||||||
var msgType = msg[0];
|
var msgType = msg[0];
|
||||||
var payload = msg.slice(1);
|
var payload = msg.slice(1);
|
||||||
if (msgType === 0x30) { // '0' = terminal output — write to xterm.js
|
if (msgType === 0x30) { // '0' = terminal output — write to xterm.js
|
||||||
_term.write(payload);
|
// decode: Uint8Array → UTF-8 string. write(Uint8Array) treats bytes as Latin-1.
|
||||||
|
_term.write(_decoder ? _decoder.decode(payload) : payload);
|
||||||
}
|
}
|
||||||
// 0x31 ('1') = window title, 0x32 ('2') = preferences — ignore for now
|
// 0x31 ('1') = window title, 0x32 ('2') = preferences — ignore for now
|
||||||
} else if (typeof e.data === 'string') {
|
} else if (typeof e.data === 'string') {
|
||||||
|
|||||||
@@ -622,11 +622,13 @@ test('message handler strips type byte and writes output for type 0x30', () => {
|
|||||||
assert.strictEqual(t.termWriteMessages.length, 1, 'term.write should be called exactly once');
|
assert.strictEqual(t.termWriteMessages.length, 1, 'term.write should be called exactly once');
|
||||||
|
|
||||||
const written = t.termWriteMessages[0];
|
const written = t.termWriteMessages[0];
|
||||||
assert.ok(written instanceof Uint8Array, 'data written to xterm must be a Uint8Array');
|
// After the UTF-8 fix: payload is decoded via TextDecoder before write(),
|
||||||
assert.strictEqual(written[0], 'h'.charCodeAt(0),
|
// so xterm.js receives a string (not raw Uint8Array).
|
||||||
'first byte written must be "h" (0x68), not the type byte 0x30');
|
// xterm.js write(Uint8Array) treated each byte as Latin-1 — TextDecoder fixes this.
|
||||||
assert.strictEqual(written.length, hello.length,
|
assert.strictEqual(typeof written, 'string',
|
||||||
'written data length must equal payload length (type byte stripped)');
|
'data written to xterm must be a decoded string (TextDecoder fix for Latin-1 garbling)');
|
||||||
|
assert.strictEqual(written, 'hello',
|
||||||
|
'decoded output must match the original ASCII payload');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('message handler ignores title type (0x31) — does not call term.write', () => {
|
test('message handler ignores title type (0x31) — does not call term.write', () => {
|
||||||
@@ -1008,6 +1010,53 @@ test('terminal.js does NOT intercept Ctrl+Shift+V — lets xterm.js handle paste
|
|||||||
'xterm.js handles paste natively with correct encoding + bracketed paste');
|
'xterm.js handles paste natively with correct encoding + bracketed paste');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- UTF-8 output decoding via TextDecoder ---
|
||||||
|
|
||||||
|
test('terminal.js uses TextDecoder to decode UTF-8 WebSocket output before writing to xterm', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
|
||||||
|
assert.ok(source.includes('TextDecoder'), 'must create a TextDecoder for UTF-8 output decoding');
|
||||||
|
// Find the message handler block for type 0x30 and verify decode() is used
|
||||||
|
const msgIdx = source.indexOf('msgType === 0x30');
|
||||||
|
assert.ok(msgIdx !== -1, 'must have a type 0x30 output handler');
|
||||||
|
const writeBlock = source.substring(msgIdx, msgIdx + 200);
|
||||||
|
assert.ok(
|
||||||
|
writeBlock.includes('decode') || writeBlock.includes('Decoder'),
|
||||||
|
'output handler must decode Uint8Array to string before _term.write() — ' +
|
||||||
|
'xterm.js write(Uint8Array) treats bytes as Latin-1 not UTF-8, ' +
|
||||||
|
'causing box-drawing chars like ─ (E2 94 80) to render as â',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('message handler writes decoded UTF-8 string (not raw Uint8Array) to xterm', () => {
|
||||||
|
const t = loadTerminal();
|
||||||
|
|
||||||
|
const orig = globalThis.setTimeout;
|
||||||
|
globalThis.setTimeout = (_fn, _ms) => 0;
|
||||||
|
|
||||||
|
t.openTerminal('test-session');
|
||||||
|
|
||||||
|
globalThis.setTimeout = orig;
|
||||||
|
|
||||||
|
// Simulate receiving a terminal output frame with a box-drawing character: ─ (U+2500)
|
||||||
|
// UTF-8 bytes for ─: E2 94 80
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const boxChar = encoder.encode('─'); // [0xE2, 0x94, 0x80]
|
||||||
|
const msg = new Uint8Array(1 + boxChar.length);
|
||||||
|
msg[0] = 0x30;
|
||||||
|
msg.set(boxChar, 1);
|
||||||
|
|
||||||
|
t.fireMessage(msg.buffer);
|
||||||
|
|
||||||
|
assert.strictEqual(t.termWriteMessages.length, 1, 'term.write should be called exactly once');
|
||||||
|
|
||||||
|
const written = t.termWriteMessages[0];
|
||||||
|
assert.strictEqual(typeof written, 'string',
|
||||||
|
'data written to xterm must be a decoded string, not a Uint8Array — ' +
|
||||||
|
'xterm.js write(Uint8Array) interprets bytes as Latin-1 causing garbled box-drawing chars');
|
||||||
|
assert.strictEqual(written, '─',
|
||||||
|
'decoded output must be the original Unicode character ─, not garbled â bytes');
|
||||||
|
});
|
||||||
|
|
||||||
// --- Federation reconnect routing ---
|
// --- Federation reconnect routing ---
|
||||||
|
|
||||||
test('terminal.js reconnect uses federation connect path for remote sessions', () => {
|
test('terminal.js reconnect uses federation connect path for remote sessions', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user