diff --git a/src/ansi-carryover.ts b/src/ansi-carryover.ts new file mode 100644 index 0000000..461a93c --- /dev/null +++ b/src/ansi-carryover.ts @@ -0,0 +1,62 @@ +/** + * Detect and split any incomplete ANSI control sequence at the end of a chunk. + * + * ANSI sequences can span multiple network packets. This function identifies + * incomplete sequences at the end of a chunk to prevent them from being + * rendered as partial/broken control codes. + * + * @param chunk - Raw output string potentially containing ANSI sequences + * @returns Tuple of [body, carry] where: + * - body: Complete portion safe to render + * - carry: Incomplete sequence to prepend to next chunk + * + * @example + * const [body, carry] = splitAnsiCarryover("hello\x1b[31"); + * // body = "hello", carry = "\x1b[31" + */ +export function splitAnsiCarryover(chunk: string): [string, string] { + if (!chunk) return ["", ""]; + + const ESC = 0x1b; + const len = chunk.length; + + // If last char is ESC, entire ESC starts a sequence we can't complete + if (chunk.charCodeAt(len - 1) === ESC) { + return [chunk.slice(0, -1), "\x1b"]; + } + + // Search from the last ESC backwards for a potentially incomplete sequence + for (let i = len - 2; i >= 0; i--) { + if (chunk.charCodeAt(i) !== ESC) continue; + const next = chunk[i + 1]; + // OSC: ESC ] ... (terminated by BEL 0x07 or ST = ESC \ + if (next === "]") { + const tail = chunk.slice(i + 2); + const hasBEL = tail.indexOf("\x07") !== -1; + const hasST = tail.indexOf("\x1b\\") !== -1; + if (!hasBEL && !hasST) { + return [chunk.slice(0, i), chunk.slice(i)]; + } + // else complete; continue scanning + continue; + } + // CSI: ESC [ params final — ensure we have a final byte 0x40-0x7E + if (next === "[") { + let j = i + 2; + while (j < len && /[0-9;?]/.test(chunk[j] || "")) j++; + // If we reached end without a final byte, it's incomplete + if (j >= len) { + return [chunk.slice(0, i), chunk.slice(i)]; + } + // Final byte exists at chunk[j]; sequence complete + continue; + } + // DCS/PM/APC and other ESC-prefixed two-char intros — if ESC is near end and likely incomplete, carry + // If ESC is the penultimate and we're at end, treat as incomplete unknown sequence + if (i >= len - 2) { + return [chunk.slice(0, i), chunk.slice(i)]; + } + // Otherwise, unknown but complete; continue + } + return [chunk, ""]; +} diff --git a/src/server.test.ts b/src/server.test.ts index 3948f32..a9801c5 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,5 +1,62 @@ import { expect, test } from "bun:test"; +import { splitAnsiCarryover } from "./ansi-carryover"; -test("placeholder", () => { - expect(true).toBe(true); +test("splitAnsiCarryover: complete chunk with no incomplete sequences", () => { + const [body, carry] = splitAnsiCarryover("hello world"); + expect(body).toBe("hello world"); + expect(carry).toBe(""); +}); + +test("splitAnsiCarryover: ESC at end of chunk", () => { + const [body, carry] = splitAnsiCarryover("hello\x1b"); + expect(body).toBe("hello"); + expect(carry).toBe("\x1b"); +}); + +test("splitAnsiCarryover: incomplete CSI sequence without final byte", () => { + const [body, carry] = splitAnsiCarryover("hello\x1b[31"); + expect(body).toBe("hello"); + expect(carry).toBe("\x1b[31"); +}); + +test("splitAnsiCarryover: incomplete OSC without BEL terminator", () => { + const [body, carry] = splitAnsiCarryover("hello\x1b]0;title"); + expect(body).toBe("hello"); + expect(carry).toBe("\x1b]0;title"); +}); + +test("splitAnsiCarryover: complete OSC with BEL terminator", () => { + const [body, carry] = splitAnsiCarryover("hello\x1b]0;title\x07world"); + expect(body).toBe("hello\x1b]0;title\x07world"); + expect(carry).toBe(""); +}); + +test("splitAnsiCarryover: complete CSI sequence", () => { + const [body, carry] = splitAnsiCarryover("hello\x1b[31mworld"); + expect(body).toBe("hello\x1b[31mworld"); + expect(carry).toBe(""); +}); + +test("splitAnsiCarryover: empty string", () => { + const [body, carry] = splitAnsiCarryover(""); + expect(body).toBe(""); + expect(carry).toBe(""); +}); + +test("splitAnsiCarryover: multiple complete sequences", () => { + const [body, carry] = splitAnsiCarryover("\x1b[1m\x1b[31mhello\x1b[0m"); + expect(body).toBe("\x1b[1m\x1b[31mhello\x1b[0m"); + expect(carry).toBe(""); +}); + +test("splitAnsiCarryover: incomplete CSI at end with complete CSI before", () => { + const [body, carry] = splitAnsiCarryover("\x1b[31mhello\x1b["); + expect(body).toBe("\x1b[31mhello"); + expect(carry).toBe("\x1b["); +}); + +test("splitAnsiCarryover: ESC with unknown sequence near end", () => { + const [body, carry] = splitAnsiCarryover("hello\x1bP"); + expect(body).toBe("hello"); + expect(carry).toBe("\x1bP"); }); diff --git a/src/server.ts b/src/server.ts index a72b06b..308519d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,7 @@ import type { ServerWebSocket } from "bun"; import { ansiToHtml } from "./ansi"; +import { splitAnsiCarryover } from "./ansi-carryover"; import { appendOutput, createDevice, @@ -81,69 +82,6 @@ function broadcastSSE(event: SSEEvent): void { } } -/** - * Detect and split any incomplete ANSI control sequence at the end of a chunk. - * - * ANSI sequences can span multiple network packets. This function identifies - * incomplete sequences at the end of a chunk to prevent them from being - * rendered as partial/broken control codes. - * - * @param chunk - Raw output string potentially containing ANSI sequences - * @returns Tuple of [body, carry] where: - * - body: Complete portion safe to render - * - carry: Incomplete sequence to prepend to next chunk - * - * @example - * const [body, carry] = splitAnsiCarryover("hello\x1b[31"); - * // body = "hello", carry = "\x1b[31" - */ -function splitAnsiCarryover(chunk: string): [string, string] { - if (!chunk) return ["", ""]; - - const ESC = 0x1b; - const len = chunk.length; - - // If last char is ESC, entire ESC starts a sequence we can't complete - if (chunk.charCodeAt(len - 1) === ESC) { - return [chunk.slice(0, -1), "\x1b"]; - } - - // Search from the last ESC backwards for a potentially incomplete sequence - for (let i = len - 2; i >= 0; i--) { - if (chunk.charCodeAt(i) !== ESC) continue; - const next = chunk[i + 1]; - // OSC: ESC ] ... (terminated by BEL 0x07 or ST = ESC \ - if (next === "]") { - const tail = chunk.slice(i + 2); - const hasBEL = tail.indexOf("\x07") !== -1; - const hasST = tail.indexOf("\x1b\\") !== -1; - if (!hasBEL && !hasST) { - return [chunk.slice(0, i), chunk.slice(i)]; - } - // else complete; continue scanning - continue; - } - // CSI: ESC [ params final — ensure we have a final byte 0x40-0x7E - if (next === "[") { - let j = i + 2; - while (j < len && /[0-9;?]/.test(chunk[j] || "")) j++; - // If we reached end without a final byte, it's incomplete - if (j >= len) { - return [chunk.slice(0, i), chunk.slice(i)]; - } - // Final byte exists at chunk[j]; sequence complete - continue; - } - // DCS/PM/APC and other ESC-prefixed two-char intros — if ESC is near end and likely incomplete, carry - // If ESC is the penultimate and we're at end, treat as incomplete unknown sequence - if (i >= len - 2) { - return [chunk.slice(0, i), chunk.slice(i)]; - } - // Otherwise, unknown but complete; continue - } - return [chunk, ""]; -} - // Initialize database const port = Number.parseInt(process.env.PORT || "7200", 10); initDb();