Simplify terminal streaming protocol

This commit is contained in:
Mohamed Boudra
2026-03-22 22:31:23 +07:00
parent 54282409bb
commit 50ea5a76fb
35 changed files with 1809 additions and 4121 deletions
+35 -45
View File
@@ -388,7 +388,7 @@ describe("Terminal", () => {
});
describe("subscribe", () => {
it("receives full state on initial subscription", async () => {
it("receives a snapshot on initial subscription", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
@@ -405,12 +405,12 @@ describe("Terminal", () => {
await new Promise((resolve) => setTimeout(resolve, 100));
expect(messages.length).toBeGreaterThan(0);
expect(messages[0].type).toBe("full");
expect(messages[0].type).toBe("snapshot");
unsubscribe();
});
it("receives messages on updates", async () => {
it("receives output and snapshot messages on updates", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
@@ -432,7 +432,8 @@ describe("Terminal", () => {
await waitForLines(session, ["$ echo test", "test", "$"]);
expect(messages.length).toBeGreaterThan(0);
expect(messages.some((message) => message.type === "output")).toBe(true);
expect(messages.some((message) => message.type === "snapshot")).toBe(true);
unsubscribe();
});
@@ -463,8 +464,8 @@ describe("Terminal", () => {
});
});
describe("raw output stream", () => {
it("streams raw output chunks with byte offsets", async () => {
describe("stream snapshots", () => {
it("streams raw output messages without replay metadata", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
@@ -475,28 +476,23 @@ describe("Terminal", () => {
await waitForLines(session, ["$"]);
const chunks: Array<{ data: string; startOffset: number; endOffset: number }> = [];
const sub = session.subscribeRaw((chunk) => {
chunks.push({
data: chunk.data,
startOffset: chunk.startOffset,
endOffset: chunk.endOffset,
});
const outputMessages: string[] = [];
const unsubscribe = session.subscribe((message) => {
if (message.type !== "output") {
return;
}
outputMessages.push(message.data);
});
session.send({ type: "input", data: "echo raw-stream\r" });
await waitForLines(session, ["$ echo raw-stream", "raw-stream", "$"]);
expect(chunks.length).toBeGreaterThan(0);
expect(chunks[chunks.length - 1].endOffset).toBe(session.getOutputOffset());
for (let i = 1; i < chunks.length; i++) {
expect(chunks[i].startOffset).toBe(chunks[i - 1].endOffset);
}
expect(outputMessages.join("")).toContain("raw-stream");
sub.unsubscribe();
unsubscribe();
});
it("replays buffered output from offset", async () => {
it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
@@ -507,21 +503,9 @@ describe("Terminal", () => {
await waitForLines(session, ["$"]);
const seen: Array<{ data: string; endOffset: number; replay: boolean }> = [];
const firstSub = session.subscribeRaw((chunk) => {
seen.push({
data: chunk.data,
endOffset: chunk.endOffset,
replay: chunk.replay,
});
});
session.send({ type: "input", data: "echo before-detach\r" });
await waitForLines(session, ["$ echo before-detach", "before-detach", "$"]);
const resumeOffset = seen[seen.length - 1]?.endOffset ?? 0;
firstSub.unsubscribe();
session.send({ type: "input", data: "echo after-detach\r" });
await waitForLines(session, [
"$ echo before-detach",
@@ -531,20 +515,26 @@ describe("Terminal", () => {
"$",
]);
const replayed: string[] = [];
const secondSub = session.subscribeRaw(
(chunk) => {
if (chunk.replay) {
replayed.push(chunk.data);
}
},
{ fromOffset: resumeOffset },
);
let snapshotText = "";
const unsubscribe = session.subscribe((message) => {
if (message.type !== "snapshot") {
return;
}
snapshotText = [...message.state.scrollback, ...message.state.grid]
.map((row) =>
row
.map((cell) => cell.char)
.join("")
.trimEnd(),
)
.join("\n");
});
const replayText = replayed.join("");
expect(replayText).toContain("after-detach");
expect(secondSub.replayedFrom).toBeGreaterThanOrEqual(resumeOffset);
secondSub.unsubscribe();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(snapshotText).toContain("before-detach");
expect(snapshotText).toContain("after-detach");
unsubscribe();
});
});
+7 -139
View File
@@ -34,35 +34,14 @@ export interface TerminalState {
cursor: Pos;
}
export interface CellChange {
row: number;
col: number;
cell: Cell;
}
export interface TerminalRawChunk {
data: string;
startOffset: number;
endOffset: number;
replay: boolean;
}
export interface TerminalRawSubscriptionResult {
unsubscribe: () => void;
replayedFrom: number;
currentOffset: number;
earliestAvailableOffset: number;
reset: boolean;
}
export type ClientMessage =
| { type: "input"; data: string }
| { type: "resize"; rows: number; cols: number }
| { type: "mouse"; row: number; col: number; button: number; action: "down" | "up" | "move" };
export type ServerMessage =
| { type: "full"; state: TerminalState }
| { type: "diff"; changes: CellChange[]; cursor: Pos };
| { type: "output"; data: string }
| { type: "snapshot"; state: TerminalState };
export interface TerminalSession {
id: string;
@@ -71,11 +50,6 @@ export interface TerminalSession {
send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void): () => void;
onExit(listener: () => void): () => void;
subscribeRaw(
listener: (chunk: TerminalRawChunk) => void,
options?: { fromOffset?: number },
): TerminalRawSubscriptionResult;
getOutputOffset(): number;
getState(): TerminalState;
kill(): void;
}
@@ -263,16 +237,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
const id = randomUUID();
const listeners = new Set<(msg: ServerMessage) => void>();
const rawListeners = new Set<(chunk: TerminalRawChunk) => void>();
const exitListeners = new Set<() => void>();
const rawOutputChunks: Array<{
startOffset: number;
endOffset: number;
data: string;
}> = [];
const maxRawBufferBytes = 8 * 1024 * 1024;
let rawBufferBytes = 0;
let outputOffset = 0;
let killed = false;
let disposed = false;
let exitEmitted = false;
@@ -329,7 +294,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
stateBroadcastScheduled = false;
terminal.dispose();
listeners.clear();
rawListeners.clear();
exitListeners.clear();
}
@@ -340,7 +304,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}
const state = getState();
for (const listener of listeners) {
listener({ type: "full", state });
listener({ type: "snapshot", state });
}
}
@@ -355,66 +319,12 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}, STATE_BROADCAST_INTERVAL_MS);
}
function appendRawOutputChunk(data: string): void {
const chunkBytes = Buffer.byteLength(data);
if (chunkBytes <= 0) {
return;
}
const startOffset = outputOffset;
const endOffset = startOffset + chunkBytes;
outputOffset = endOffset;
rawOutputChunks.push({
startOffset,
endOffset,
data,
});
rawBufferBytes += chunkBytes;
while (rawBufferBytes > maxRawBufferBytes && rawOutputChunks.length > 0) {
const removed = rawOutputChunks.shift();
if (!removed) {
break;
}
rawBufferBytes -= removed.endOffset - removed.startOffset;
}
const chunk: TerminalRawChunk = {
data,
startOffset,
endOffset,
replay: false,
};
for (const listener of rawListeners) {
listener(chunk);
}
}
function getEarliestAvailableOffset(): number {
const earliest = rawOutputChunks[0];
return earliest ? earliest.startOffset : outputOffset;
}
function alignOffsetToChunkBoundary(offset: number): number {
if (offset === outputOffset) {
return offset;
}
for (const chunk of rawOutputChunks) {
if (offset === chunk.startOffset || offset === chunk.endOffset) {
return offset;
}
if (offset > chunk.startOffset && offset < chunk.endOffset) {
return chunk.startOffset;
}
}
return offset;
}
// Pipe PTY output to terminal emulator
ptyProcess.onData((data) => {
if (killed) return;
appendRawOutputChunk(data);
for (const listener of listeners) {
listener({ type: "output", data });
}
terminal.write(data, () => {
scheduleStateBroadcast();
});
@@ -460,10 +370,9 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
function subscribe(listener: (msg: ServerMessage) => void): () => void {
listeners.add(listener);
// Send initial full state
queueMicrotask(() => {
if (listeners.has(listener)) {
listener({ type: "full", state: getState() });
listener({ type: "snapshot", state: getState() });
}
});
@@ -490,45 +399,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
};
}
function subscribeRaw(
listener: (chunk: TerminalRawChunk) => void,
options?: { fromOffset?: number },
): TerminalRawSubscriptionResult {
const requestedOffset = Math.max(0, Math.floor(options?.fromOffset ?? 0));
const earliestAvailableOffset = getEarliestAvailableOffset();
const clampedOffset = Math.max(requestedOffset, earliestAvailableOffset);
const replayedFrom = alignOffsetToChunkBoundary(clampedOffset);
const reset = replayedFrom !== requestedOffset;
for (const chunk of rawOutputChunks) {
if (chunk.endOffset <= replayedFrom) {
continue;
}
listener({
data: chunk.data,
startOffset: chunk.startOffset,
endOffset: chunk.endOffset,
replay: true,
});
}
rawListeners.add(listener);
return {
unsubscribe: () => {
rawListeners.delete(listener);
},
replayedFrom,
currentOffset: outputOffset,
earliestAvailableOffset,
reset,
};
}
function getOutputOffset(): number {
return outputOffset;
}
function kill(): void {
if (!killed) {
killed = true;
@@ -548,8 +418,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
send,
subscribe,
onExit,
subscribeRaw,
getOutputOffset,
getState,
kill,
};