mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 07:33:18 +00:00
Restore configured terminal history on reconnect, retaining 10,000 rows plus the screen. Serialize the worker-owned xterm buffer directly to ANSI and forward negotiated bounded replay chunks while preserving ANSI-era clients and per-viewer live ordering. Preserve captured geometry through replay, bound pressure recovery to 500 rows, and keep the omitted visible-history default at 200. Remove the full cell-grid copy and obsolete JSON rendering. All 21 CI checks pass. Real browser, native Debug emulator/simulator and Linux Electron coverage and platform limits are documented in the PR. The Android Debug invalid native delegate dispatch also reproduces on upstream main; its cause and any change in frequency remain unproven. Credit Manav Dhindsa (DrakenKor), #6139 and #6148, and Lennart Schoch, #6743, for prior ANSI replay and retention work informing this change. Closes #6137. Closes #6147.
107 lines
3.6 KiB
TypeScript
107 lines
3.6 KiB
TypeScript
import {
|
|
TerminalStreamOpcode,
|
|
TerminalReplayMetadataSchema,
|
|
type TerminalStreamFrame,
|
|
} from "@getpaseo/protocol/binary-frames/index";
|
|
|
|
export type TerminalReplayEvent =
|
|
| { type: "begin"; rows?: number; cols?: number; retainedHistoryLines?: number }
|
|
| { type: "data"; data: Uint8Array }
|
|
| { type: "end" };
|
|
|
|
export type TerminalStreamEvent = { terminalId: string; subscriptionId: string } & (
|
|
| { type: "output"; data: Uint8Array }
|
|
| { type: "replay"; replay: TerminalReplayEvent }
|
|
);
|
|
|
|
export class TerminalStreamRouter {
|
|
private readonly slots = new Map<
|
|
number,
|
|
Set<{
|
|
subscriptionId: string;
|
|
terminalId: string;
|
|
receive: (event: TerminalStreamEvent) => void;
|
|
}>
|
|
>();
|
|
private readonly listeners = new Set<(event: TerminalStreamEvent) => void>();
|
|
|
|
/** Passive wire diagnostics. App state consumes the registration's callback. */
|
|
onEvent(handler: (event: TerminalStreamEvent) => void): () => void {
|
|
this.listeners.add(handler);
|
|
return () => {
|
|
this.listeners.delete(handler);
|
|
};
|
|
}
|
|
|
|
attach(
|
|
subscriptionId: string,
|
|
terminalId: string,
|
|
slot: number,
|
|
receive: (event: TerminalStreamEvent) => void,
|
|
): () => void {
|
|
const registration = { subscriptionId, terminalId, receive };
|
|
const registrations = this.slots.get(slot) ?? new Set();
|
|
registrations.add(registration);
|
|
this.slots.set(slot, registrations);
|
|
return () => {
|
|
registrations.delete(registration);
|
|
if (registrations.size === 0) this.slots.delete(slot);
|
|
};
|
|
}
|
|
|
|
clearSlots(): void {
|
|
this.slots.clear();
|
|
}
|
|
|
|
handleFrame(frame: TerminalStreamFrame): void {
|
|
const registrations = this.slots.get(frame.slot);
|
|
if (!registrations) return;
|
|
for (const registration of registrations) {
|
|
const identity = {
|
|
subscriptionId: registration.subscriptionId,
|
|
terminalId: registration.terminalId,
|
|
};
|
|
let event: TerminalStreamEvent;
|
|
if (frame.opcode === TerminalStreamOpcode.Output)
|
|
event = { ...identity, type: "output", data: frame.payload };
|
|
else if (frame.opcode === TerminalStreamOpcode.Restore) {
|
|
// COMPAT(terminalSingleRestore): added after v0.14.0; remove when ANSI host floor supports chunked replay after 2027-04-30.
|
|
for (const replay of [
|
|
{ type: "begin" },
|
|
{ type: "data", data: frame.payload },
|
|
{ type: "end" },
|
|
] as TerminalReplayEvent[])
|
|
this.deliver(registration.receive, { ...identity, type: "replay", replay });
|
|
continue;
|
|
} else if (frame.opcode === TerminalStreamOpcode.ReplayBegin) {
|
|
let raw: unknown;
|
|
try {
|
|
raw = JSON.parse(new TextDecoder().decode(frame.payload));
|
|
} catch {
|
|
return;
|
|
}
|
|
const parsed = TerminalReplayMetadataSchema.safeParse(raw);
|
|
if (!parsed.success) return;
|
|
const metadata = parsed.data;
|
|
event = { ...identity, type: "replay", replay: { type: "begin", ...metadata } };
|
|
} else if (frame.opcode === TerminalStreamOpcode.ReplayData)
|
|
event = { ...identity, type: "replay", replay: { type: "data", data: frame.payload } };
|
|
else if (frame.opcode === TerminalStreamOpcode.ReplayEnd)
|
|
event = { ...identity, type: "replay", replay: { type: "end" } };
|
|
else return;
|
|
this.deliver(registration.receive, event);
|
|
}
|
|
}
|
|
|
|
private deliver(receive: (event: TerminalStreamEvent) => void, event: TerminalStreamEvent): void {
|
|
receive(event);
|
|
for (const listener of this.listeners) {
|
|
try {
|
|
listener(event);
|
|
} catch {
|
|
/* Passive diagnostics cannot interrupt delivery. */
|
|
}
|
|
}
|
|
}
|
|
}
|