Simplify terminal streaming protocol
This commit is contained in:
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BinaryMuxChannel,
|
||||
TerminalBinaryFlags,
|
||||
TerminalBinaryMessageType,
|
||||
asUint8Array,
|
||||
decodeBinaryMuxFrame,
|
||||
encodeBinaryMuxFrame,
|
||||
} from "./binary-mux.js";
|
||||
|
||||
describe("binary mux frame codec", () => {
|
||||
it("encodes and decodes round trip", () => {
|
||||
const payload = new TextEncoder().encode("hello");
|
||||
const encoded = encodeBinaryMuxFrame({
|
||||
channel: BinaryMuxChannel.Terminal,
|
||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
||||
streamId: 42,
|
||||
offset: 1234,
|
||||
flags: TerminalBinaryFlags.Replay,
|
||||
payload,
|
||||
});
|
||||
|
||||
const decoded = decodeBinaryMuxFrame(encoded);
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded!.channel).toBe(BinaryMuxChannel.Terminal);
|
||||
expect(decoded!.messageType).toBe(TerminalBinaryMessageType.OutputUtf8);
|
||||
expect(decoded!.streamId).toBe(42);
|
||||
expect(decoded!.offset).toBe(1234);
|
||||
expect(decoded!.flags).toBe(TerminalBinaryFlags.Replay);
|
||||
expect(Array.from(decoded!.payload ?? [])).toEqual(Array.from(payload));
|
||||
});
|
||||
|
||||
it("rejects malformed frame payload length", () => {
|
||||
const encoded = encodeBinaryMuxFrame({
|
||||
channel: BinaryMuxChannel.Terminal,
|
||||
messageType: TerminalBinaryMessageType.InputUtf8,
|
||||
streamId: 1,
|
||||
offset: 0,
|
||||
payload: new Uint8Array([1, 2, 3]),
|
||||
});
|
||||
const tampered = encoded.slice(0, encoded.byteLength - 1);
|
||||
expect(decodeBinaryMuxFrame(tampered)).toBeNull();
|
||||
});
|
||||
|
||||
it("converts UTF-8 string payloads to bytes", () => {
|
||||
const bytes = asUint8Array("hello");
|
||||
expect(bytes).not.toBeNull();
|
||||
expect(Array.from(bytes ?? [])).toEqual(Array.from(new TextEncoder().encode("hello")));
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
const MUX_MAGIC_1 = 0x50; // 'P'
|
||||
const MUX_MAGIC_2 = 0x58; // 'X'
|
||||
const MUX_VERSION = 1;
|
||||
const HEADER_SIZE = 24;
|
||||
const UINT32_MAX = 0xffffffff;
|
||||
const TWO_POW_32 = 0x1_0000_0000;
|
||||
|
||||
export const BinaryMuxConstants = {
|
||||
magic1: MUX_MAGIC_1,
|
||||
magic2: MUX_MAGIC_2,
|
||||
version: MUX_VERSION,
|
||||
headerSize: HEADER_SIZE,
|
||||
} as const;
|
||||
|
||||
export const enum BinaryMuxChannel {
|
||||
Terminal = 1,
|
||||
FileTransfer = 2,
|
||||
}
|
||||
|
||||
export const enum TerminalBinaryMessageType {
|
||||
InputUtf8 = 1,
|
||||
OutputUtf8 = 2,
|
||||
Ack = 3,
|
||||
}
|
||||
|
||||
export const enum TerminalBinaryFlags {
|
||||
Replay = 1,
|
||||
}
|
||||
|
||||
export interface BinaryMuxFrame {
|
||||
channel: number;
|
||||
messageType: number;
|
||||
streamId: number;
|
||||
offset: number;
|
||||
flags?: number;
|
||||
payload?: Uint8Array;
|
||||
}
|
||||
|
||||
export function asUint8Array(data: unknown): Uint8Array | null {
|
||||
if (typeof data === "string") {
|
||||
if (typeof TextEncoder !== "undefined") {
|
||||
return new TextEncoder().encode(data);
|
||||
}
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return new Uint8Array(Buffer.from(data, "utf8"));
|
||||
}
|
||||
const out = new Uint8Array(data.length);
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
out[i] = data.charCodeAt(i) & 0xff;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (data instanceof Uint8Array) {
|
||||
return data;
|
||||
}
|
||||
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isLikelyBinaryMuxFrame(data: Uint8Array): boolean {
|
||||
if (data.byteLength < HEADER_SIZE) {
|
||||
return false;
|
||||
}
|
||||
return data[0] === MUX_MAGIC_1 && data[1] === MUX_MAGIC_2 && data[2] === MUX_VERSION;
|
||||
}
|
||||
|
||||
export function encodeBinaryMuxFrame(frame: BinaryMuxFrame): Uint8Array {
|
||||
const payload = frame.payload ?? new Uint8Array(0);
|
||||
const out = new Uint8Array(HEADER_SIZE + payload.byteLength);
|
||||
const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
|
||||
const flags = frame.flags ?? 0;
|
||||
|
||||
if (!Number.isFinite(frame.streamId) || frame.streamId < 0 || frame.streamId > UINT32_MAX) {
|
||||
throw new Error(`Invalid streamId: ${frame.streamId}`);
|
||||
}
|
||||
if (!Number.isFinite(frame.offset) || frame.offset < 0) {
|
||||
throw new Error(`Invalid offset: ${frame.offset}`);
|
||||
}
|
||||
|
||||
const offsetHi = Math.floor(frame.offset / TWO_POW_32);
|
||||
const offsetLo = frame.offset >>> 0;
|
||||
if (offsetHi > UINT32_MAX) {
|
||||
throw new Error(`Offset too large: ${frame.offset}`);
|
||||
}
|
||||
|
||||
view.setUint8(0, MUX_MAGIC_1);
|
||||
view.setUint8(1, MUX_MAGIC_2);
|
||||
view.setUint8(2, MUX_VERSION);
|
||||
view.setUint8(3, frame.channel & 0xff);
|
||||
view.setUint8(4, frame.messageType & 0xff);
|
||||
view.setUint8(5, flags & 0xff);
|
||||
view.setUint8(6, 0);
|
||||
view.setUint8(7, 0);
|
||||
view.setUint32(8, frame.streamId >>> 0);
|
||||
view.setUint32(12, offsetHi >>> 0);
|
||||
view.setUint32(16, offsetLo);
|
||||
view.setUint32(20, payload.byteLength >>> 0);
|
||||
|
||||
out.set(payload, HEADER_SIZE);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeBinaryMuxFrame(data: Uint8Array): BinaryMuxFrame | null {
|
||||
if (!isLikelyBinaryMuxFrame(data)) {
|
||||
return null;
|
||||
}
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
const payloadLength = view.getUint32(20);
|
||||
if (payloadLength !== data.byteLength - HEADER_SIZE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const offsetHi = view.getUint32(12);
|
||||
const offsetLo = view.getUint32(16);
|
||||
const offset = offsetHi * TWO_POW_32 + offsetLo;
|
||||
|
||||
return {
|
||||
channel: view.getUint8(3),
|
||||
messageType: view.getUint8(4),
|
||||
flags: view.getUint8(5),
|
||||
streamId: view.getUint32(8),
|
||||
offset,
|
||||
payload: data.subarray(HEADER_SIZE),
|
||||
};
|
||||
}
|
||||
@@ -1139,21 +1139,6 @@ export const KillTerminalRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const AttachTerminalStreamRequestSchema = z.object({
|
||||
type: z.literal("attach_terminal_stream_request"),
|
||||
terminalId: z.string(),
|
||||
resumeOffset: z.number().int().nonnegative().optional(),
|
||||
rows: z.number().int().positive().optional(),
|
||||
cols: z.number().int().positive().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const DetachTerminalStreamRequestSchema = z.object({
|
||||
type: z.literal("detach_terminal_stream_request"),
|
||||
streamId: z.number().int().nonnegative(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
VoiceAudioChunkMessageSchema,
|
||||
AbortRequestMessageSchema,
|
||||
@@ -1219,8 +1204,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
UnsubscribeTerminalRequestSchema,
|
||||
TerminalInputSchema,
|
||||
KillTerminalRequestSchema,
|
||||
AttachTerminalStreamRequestSchema,
|
||||
DetachTerminalStreamRequestSchema,
|
||||
]);
|
||||
|
||||
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
||||
@@ -2112,7 +2095,7 @@ const TerminalInfoSchema = z.object({
|
||||
cwd: z.string(),
|
||||
});
|
||||
|
||||
const TerminalCellSchema = z.object({
|
||||
export const TerminalCellSchema = z.object({
|
||||
char: z.string(),
|
||||
fg: z.number().optional(),
|
||||
bg: z.number().optional(),
|
||||
@@ -2123,7 +2106,7 @@ const TerminalCellSchema = z.object({
|
||||
underline: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const TerminalStateSchema = z.object({
|
||||
export const TerminalStateSchema = z.object({
|
||||
rows: z.number(),
|
||||
cols: z.number(),
|
||||
grid: z.array(z.array(TerminalCellSchema)),
|
||||
@@ -2167,14 +2150,6 @@ export const SubscribeTerminalResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const TerminalOutputSchema = z.object({
|
||||
type: z.literal("terminal_output"),
|
||||
payload: z.object({
|
||||
terminalId: z.string(),
|
||||
state: TerminalStateSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
export const KillTerminalResponseSchema = z.object({
|
||||
type: z.literal("kill_terminal_response"),
|
||||
payload: z.object({
|
||||
@@ -2184,33 +2159,9 @@ export const KillTerminalResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const AttachTerminalStreamResponseSchema = z.object({
|
||||
type: z.literal("attach_terminal_stream_response"),
|
||||
payload: z.object({
|
||||
terminalId: z.string(),
|
||||
streamId: z.number().int().nonnegative().nullable(),
|
||||
replayedFrom: z.number().int().nonnegative(),
|
||||
currentOffset: z.number().int().nonnegative(),
|
||||
earliestAvailableOffset: z.number().int().nonnegative(),
|
||||
reset: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const DetachTerminalStreamResponseSchema = z.object({
|
||||
type: z.literal("detach_terminal_stream_response"),
|
||||
payload: z.object({
|
||||
streamId: z.number().int().nonnegative(),
|
||||
success: z.boolean(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const TerminalStreamExitSchema = z.object({
|
||||
type: z.literal("terminal_stream_exit"),
|
||||
payload: z.object({
|
||||
streamId: z.number().int().nonnegative(),
|
||||
terminalId: z.string(),
|
||||
}),
|
||||
});
|
||||
@@ -2278,10 +2229,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
TerminalsChangedSchema,
|
||||
CreateTerminalResponseSchema,
|
||||
SubscribeTerminalResponseSchema,
|
||||
TerminalOutputSchema,
|
||||
KillTerminalResponseSchema,
|
||||
AttachTerminalStreamResponseSchema,
|
||||
DetachTerminalStreamResponseSchema,
|
||||
TerminalStreamExitSchema,
|
||||
]);
|
||||
|
||||
@@ -2415,13 +2363,10 @@ export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSc
|
||||
export type SubscribeTerminalResponse = z.infer<typeof SubscribeTerminalResponseSchema>;
|
||||
export type UnsubscribeTerminalRequest = z.infer<typeof UnsubscribeTerminalRequestSchema>;
|
||||
export type TerminalInput = z.infer<typeof TerminalInputSchema>;
|
||||
export type TerminalOutput = z.infer<typeof TerminalOutputSchema>;
|
||||
export type TerminalCell = z.infer<typeof TerminalCellSchema>;
|
||||
export type TerminalState = z.infer<typeof TerminalStateSchema>;
|
||||
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
|
||||
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>;
|
||||
export type AttachTerminalStreamRequest = z.infer<typeof AttachTerminalStreamRequestSchema>;
|
||||
export type AttachTerminalStreamResponse = z.infer<typeof AttachTerminalStreamResponseSchema>;
|
||||
export type DetachTerminalStreamRequest = z.infer<typeof DetachTerminalStreamRequestSchema>;
|
||||
export type DetachTerminalStreamResponse = z.infer<typeof DetachTerminalStreamResponseSchema>;
|
||||
export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalResizePayload,
|
||||
decodeTerminalSnapshotPayload,
|
||||
decodeTerminalStreamFrame,
|
||||
encodeTerminalResizePayload,
|
||||
encodeTerminalSnapshotPayload,
|
||||
encodeTerminalStreamFrame,
|
||||
} from "./terminal-stream-protocol.js";
|
||||
|
||||
describe("terminal stream protocol", () => {
|
||||
it("encodes output frames as a 1-byte opcode prefix plus payload", () => {
|
||||
const payload = new TextEncoder().encode("hello");
|
||||
const encoded = encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(encoded[0]).toBe(TerminalStreamOpcode.Output);
|
||||
expect(Array.from(encoded.subarray(1))).toEqual(Array.from(payload));
|
||||
|
||||
const decoded = decodeTerminalStreamFrame(encoded);
|
||||
expect(decoded).toEqual({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
payload,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips resize payloads", () => {
|
||||
const payload = encodeTerminalResizePayload({
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
|
||||
expect(decodeTerminalResizePayload(payload)).toEqual({
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips snapshot payloads", () => {
|
||||
const state = {
|
||||
rows: 1,
|
||||
cols: 2,
|
||||
grid: [[{ char: "A" }, { char: "B" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 2 },
|
||||
};
|
||||
|
||||
const payload = encodeTerminalSnapshotPayload(state);
|
||||
expect(decodeTerminalSnapshotPayload(payload)).toEqual(state);
|
||||
});
|
||||
|
||||
it("rejects unknown opcodes", () => {
|
||||
expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01]))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { z } from "zod";
|
||||
import { TerminalStateSchema } from "./messages.js";
|
||||
|
||||
export const TerminalStreamResizeSchema = z.object({
|
||||
rows: z.number().int().positive(),
|
||||
cols: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const TerminalStreamOpcode = {
|
||||
Output: 0x01,
|
||||
Input: 0x02,
|
||||
Resize: 0x03,
|
||||
Snapshot: 0x04,
|
||||
} as const;
|
||||
|
||||
export type TerminalStreamOpcode =
|
||||
(typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
|
||||
|
||||
export type TerminalStreamFrame = {
|
||||
opcode: TerminalStreamOpcode;
|
||||
payload: Uint8Array;
|
||||
};
|
||||
|
||||
export function asUint8Array(data: unknown): Uint8Array | null {
|
||||
if (typeof data === "string") {
|
||||
if (typeof TextEncoder !== "undefined") {
|
||||
return new TextEncoder().encode(data);
|
||||
}
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return new Uint8Array(Buffer.from(data, "utf8"));
|
||||
}
|
||||
const out = new Uint8Array(data.length);
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
out[index] = data.charCodeAt(index) & 0xff;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (data instanceof Uint8Array) {
|
||||
return data;
|
||||
}
|
||||
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
|
||||
return (
|
||||
value === TerminalStreamOpcode.Output ||
|
||||
value === TerminalStreamOpcode.Input ||
|
||||
value === TerminalStreamOpcode.Resize ||
|
||||
value === TerminalStreamOpcode.Snapshot
|
||||
);
|
||||
}
|
||||
|
||||
export function encodeTerminalStreamFrame(input: {
|
||||
opcode: TerminalStreamOpcode;
|
||||
payload?: Uint8Array | ArrayBuffer | string;
|
||||
}): Uint8Array {
|
||||
const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0);
|
||||
const bytes = new Uint8Array(1 + payload.byteLength);
|
||||
bytes[0] = input.opcode;
|
||||
bytes.set(payload, 1);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
|
||||
if (bytes.byteLength < 1) {
|
||||
return null;
|
||||
}
|
||||
const opcode = bytes[0];
|
||||
if (!isTerminalStreamOpcode(opcode)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
opcode,
|
||||
payload: bytes.subarray(1),
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeTerminalSnapshotPayload(state: z.infer<typeof TerminalStateSchema>): Uint8Array {
|
||||
return encodeJsonPayload(state);
|
||||
}
|
||||
|
||||
export function decodeTerminalSnapshotPayload(
|
||||
bytes: Uint8Array,
|
||||
): z.infer<typeof TerminalStateSchema> | null {
|
||||
const parsed = decodeJsonPayload(bytes);
|
||||
const result = TerminalStateSchema.safeParse(parsed);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
export function encodeTerminalResizePayload(
|
||||
input: z.infer<typeof TerminalStreamResizeSchema>,
|
||||
): Uint8Array {
|
||||
return encodeJsonPayload(input);
|
||||
}
|
||||
|
||||
export function decodeTerminalResizePayload(
|
||||
bytes: Uint8Array,
|
||||
): z.infer<typeof TerminalStreamResizeSchema> | null {
|
||||
const parsed = decodeJsonPayload(bytes);
|
||||
const result = TerminalStreamResizeSchema.safeParse(parsed);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
function encodeJsonPayload(value: unknown): Uint8Array {
|
||||
return new TextEncoder().encode(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function decodeJsonPayload(bytes: Uint8Array): unknown {
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
return JSON.parse(text);
|
||||
}
|
||||
Reference in New Issue
Block a user