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:
Brian Krabach
2026-04-06 22:16:19 -07:00
parent c71a551b06
commit ced0c62dd6
2 changed files with 61 additions and 6 deletions
+7 -1
View File
@@ -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') {
+54 -5
View File
@@ -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', () => {