feat: stream files as binary frames over WebSocket (#659)
Replace base64-in-JSON file transfers with binary WebSocket frames to
unblock the JS thread when assistant markdown messages contain images.
Receive cost (JSON.parse over multi-MB base64) and persist cost (base64
re-encode to disk) both removed.
- Split daemon-client `exploreFileSystem` into `listDirectory` + `readFile`;
`readFile` returns `{ bytes, mime, size, modifiedAt }`. Encoding stays
private to the seam.
- New `packages/server/src/shared/binary-frames/` module hosts terminal
frames (moved from `terminal-stream-protocol.ts`) and new file-transfer
opcodes (`FileBegin` / `FileChunk` / `FileEnd`) with request-id correlation.
- Add optional `acceptBinary` to `FileExplorerRequestSchema`. New daemons
emit binary frames when set; legacy JSON path remains for old clients.
Compat fallback lives only inside `readFile`.
- New `persistAttachmentFromBytes` writes bytes directly: native via
`expo-file-system` `File.write(Uint8Array)`, web via Blob -> IndexedDB,
desktop via new `write_attachment_bytes` IPC. `persistAttachmentFromBase64`
removed.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FileTransferOpcode,
|
||||
TerminalStreamOpcode,
|
||||
decodeFileTransferFrame,
|
||||
decodeTerminalStreamFrame,
|
||||
encodeFileTransferFrame,
|
||||
} from "./index.js";
|
||||
|
||||
describe("file transfer binary frames", () => {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
it("uses non-terminal opcodes", () => {
|
||||
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileBegin);
|
||||
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileChunk);
|
||||
expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileEnd);
|
||||
});
|
||||
|
||||
it("encodes FileBegin as opcode plus request id prefix plus JSON metadata", () => {
|
||||
const encoded = encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId: "req-1",
|
||||
metadata: {
|
||||
mime: "image/png",
|
||||
size: 6,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
const requestId = encoder.encode("req-1");
|
||||
const metadata = encoder.encode(
|
||||
JSON.stringify({
|
||||
mime: "image/png",
|
||||
size: 6,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(decodeTerminalStreamFrame(encoded)).toBeNull();
|
||||
expect(encoded[0]).toBe(FileTransferOpcode.FileBegin);
|
||||
expect(encoded[1]).toBe(requestId.byteLength);
|
||||
expect(encoded.subarray(2, 2 + requestId.byteLength)).toEqual(requestId);
|
||||
expect(
|
||||
new DataView(encoded.buffer, encoded.byteOffset).getUint16(2 + requestId.byteLength),
|
||||
).toBe(metadata.byteLength);
|
||||
expect(encoded.subarray(4 + requestId.byteLength)).toEqual(metadata);
|
||||
|
||||
expect(decodeFileTransferFrame(encoded)).toEqual({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId: "req-1",
|
||||
metadata: {
|
||||
mime: "image/png",
|
||||
size: 6,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
},
|
||||
payload: new Uint8Array(),
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes FileChunk as opcode plus request id prefix plus binary payload", () => {
|
||||
const payload = new Uint8Array([0, 1, 2, 253, 254, 255]);
|
||||
const encoded = encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId: "req-1",
|
||||
payload,
|
||||
});
|
||||
const requestId = encoder.encode("req-1");
|
||||
|
||||
expect(encoded[0]).toBe(FileTransferOpcode.FileChunk);
|
||||
expect(encoded[1]).toBe(requestId.byteLength);
|
||||
expect(encoded.subarray(2, 2 + requestId.byteLength)).toEqual(requestId);
|
||||
expect(encoded.subarray(2 + requestId.byteLength)).toEqual(payload);
|
||||
const decoded = decodeFileTransferFrame(encoded);
|
||||
|
||||
expect(decoded).toEqual({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId: "req-1",
|
||||
payload,
|
||||
});
|
||||
expect(decoded?.payload).toBeInstanceOf(Uint8Array);
|
||||
expect(decoded?.payload).toEqual(payload);
|
||||
});
|
||||
|
||||
it("encodes FileEnd as opcode plus request id prefix only", () => {
|
||||
const encoded = encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileEnd,
|
||||
requestId: "req-1",
|
||||
});
|
||||
const requestId = encoder.encode("req-1");
|
||||
|
||||
expect(encoded).toEqual(
|
||||
new Uint8Array([FileTransferOpcode.FileEnd, requestId.byteLength, ...requestId]),
|
||||
);
|
||||
|
||||
expect(decodeFileTransferFrame(encoded)).toEqual({
|
||||
opcode: FileTransferOpcode.FileEnd,
|
||||
requestId: "req-1",
|
||||
payload: new Uint8Array(),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed metadata and unknown metadata fields", () => {
|
||||
expect(
|
||||
decodeFileTransferFrame(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId: "req-1",
|
||||
metadata: {
|
||||
mime: "image/png",
|
||||
size: -1,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
const json = encoder.encode(
|
||||
JSON.stringify({
|
||||
mime: "image/png",
|
||||
size: 1,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
extra: true,
|
||||
}),
|
||||
);
|
||||
const requestId = encoder.encode("req-1");
|
||||
const encoded = new Uint8Array(4 + requestId.byteLength + json.byteLength);
|
||||
encoded[0] = FileTransferOpcode.FileBegin;
|
||||
encoded[1] = requestId.byteLength;
|
||||
encoded.set(requestId, 2);
|
||||
new DataView(encoded.buffer).setUint16(2 + requestId.byteLength, json.byteLength);
|
||||
encoded.set(json, 4 + requestId.byteLength);
|
||||
|
||||
expect(decodeFileTransferFrame(encoded)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed request id prefixes and frame tails", () => {
|
||||
expect(decodeFileTransferFrame(new Uint8Array([FileTransferOpcode.FileEnd, 0]))).toBeNull();
|
||||
expect(decodeFileTransferFrame(new Uint8Array([FileTransferOpcode.FileEnd, 6, 1]))).toBeNull();
|
||||
|
||||
const requestId = encoder.encode("req-1");
|
||||
const encoded = new Uint8Array([
|
||||
FileTransferOpcode.FileEnd,
|
||||
requestId.byteLength,
|
||||
...requestId,
|
||||
1,
|
||||
]);
|
||||
expect(decodeFileTransferFrame(encoded)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { z } from "zod";
|
||||
import { asUint8Array } from "./terminal.js";
|
||||
|
||||
export const FileTransferOpcode = {
|
||||
FileBegin: 0x10,
|
||||
FileChunk: 0x11,
|
||||
FileEnd: 0x12,
|
||||
} as const;
|
||||
|
||||
export type FileTransferOpcode = (typeof FileTransferOpcode)[keyof typeof FileTransferOpcode];
|
||||
|
||||
export const FileBeginMetadataSchema = z
|
||||
.object({
|
||||
mime: z.string().min(1),
|
||||
size: z.number().int().nonnegative(),
|
||||
encoding: z.enum(["utf-8", "binary"]),
|
||||
modifiedAt: z.string(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export interface FileBegin {
|
||||
opcode: typeof FileTransferOpcode.FileBegin;
|
||||
requestId: string;
|
||||
metadata: z.infer<typeof FileBeginMetadataSchema>;
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
export interface FileChunk {
|
||||
opcode: typeof FileTransferOpcode.FileChunk;
|
||||
requestId: string;
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
export interface FileEnd {
|
||||
opcode: typeof FileTransferOpcode.FileEnd;
|
||||
requestId: string;
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
export type FileTransferFrame = FileBegin | FileChunk | FileEnd;
|
||||
|
||||
type FileTransferFrameInput =
|
||||
| {
|
||||
opcode: typeof FileTransferOpcode.FileBegin;
|
||||
requestId: string;
|
||||
metadata: z.infer<typeof FileBeginMetadataSchema>;
|
||||
}
|
||||
| {
|
||||
opcode: typeof FileTransferOpcode.FileChunk;
|
||||
requestId: string;
|
||||
payload?: Uint8Array | ArrayBuffer | string;
|
||||
}
|
||||
| {
|
||||
opcode: typeof FileTransferOpcode.FileEnd;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
export function encodeFileTransferFrame(input: FileTransferFrameInput): Uint8Array {
|
||||
const requestId = encodeRequestId(input.requestId);
|
||||
|
||||
if (input.opcode === FileTransferOpcode.FileBegin) {
|
||||
const metadata = encodeJsonPayload(input.metadata);
|
||||
if (metadata.byteLength > 0xffff) {
|
||||
throw new RangeError("FileBegin metadata is too long");
|
||||
}
|
||||
const bytes = new Uint8Array(4 + requestId.byteLength + metadata.byteLength);
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
bytes[0] = input.opcode;
|
||||
bytes[1] = requestId.byteLength;
|
||||
bytes.set(requestId, 2);
|
||||
view.setUint16(2 + requestId.byteLength, metadata.byteLength);
|
||||
bytes.set(metadata, 4 + requestId.byteLength);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const payload =
|
||||
input.opcode === FileTransferOpcode.FileChunk
|
||||
? (asUint8Array(input.payload ?? new Uint8Array()) ?? new Uint8Array())
|
||||
: new Uint8Array();
|
||||
const bytes = new Uint8Array(2 + requestId.byteLength + payload.byteLength);
|
||||
bytes[0] = input.opcode;
|
||||
bytes[1] = requestId.byteLength;
|
||||
bytes.set(requestId, 2);
|
||||
bytes.set(payload, 2 + requestId.byteLength);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function decodeFileTransferFrame(bytes: Uint8Array): FileTransferFrame | null {
|
||||
if (bytes.byteLength < 2) {
|
||||
return null;
|
||||
}
|
||||
const opcode = bytes[0];
|
||||
if (!isFileTransferOpcode(opcode)) {
|
||||
return null;
|
||||
}
|
||||
const requestIdLength = bytes[1];
|
||||
if (requestIdLength === 0 || requestIdLength > bytes.byteLength - 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestId = decodeRequestId(bytes.subarray(2, 2 + requestIdLength));
|
||||
const body = bytes.subarray(2 + requestIdLength);
|
||||
|
||||
if (opcode === FileTransferOpcode.FileBegin) {
|
||||
if (body.byteLength < 2) {
|
||||
return null;
|
||||
}
|
||||
const view = new DataView(body.buffer, body.byteOffset, body.byteLength);
|
||||
const metadataLength = view.getUint16(0);
|
||||
if (metadataLength !== body.byteLength - 2) {
|
||||
return null;
|
||||
}
|
||||
const metadataBytes = body.subarray(2);
|
||||
const result = FileBeginMetadataSchema.safeParse(decodeJsonPayload(metadataBytes));
|
||||
return result.success
|
||||
? { opcode, requestId, metadata: result.data, payload: new Uint8Array() }
|
||||
: null;
|
||||
}
|
||||
|
||||
if (opcode === FileTransferOpcode.FileChunk) {
|
||||
return { opcode, requestId, payload: body };
|
||||
}
|
||||
|
||||
if (body.byteLength !== 0) {
|
||||
return null;
|
||||
}
|
||||
return { opcode, requestId, payload: new Uint8Array() };
|
||||
}
|
||||
|
||||
function isFileTransferOpcode(value: number): value is FileTransferOpcode {
|
||||
return (
|
||||
value === FileTransferOpcode.FileBegin ||
|
||||
value === FileTransferOpcode.FileChunk ||
|
||||
value === FileTransferOpcode.FileEnd
|
||||
);
|
||||
}
|
||||
|
||||
function encodeJsonPayload(value: unknown): Uint8Array {
|
||||
return new TextEncoder().encode(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function encodeRequestId(requestId: string): Uint8Array {
|
||||
const bytes = new TextEncoder().encode(requestId);
|
||||
if (bytes.byteLength === 0) {
|
||||
throw new RangeError("File transfer requestId is required");
|
||||
}
|
||||
if (bytes.byteLength > 0xff) {
|
||||
throw new RangeError("File transfer requestId is too long");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function decodeRequestId(bytes: Uint8Array): string {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function decodeJsonPayload(bytes: Uint8Array): unknown {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./file-transfer.js";
|
||||
export * from "./terminal.js";
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalResizePayload,
|
||||
decodeTerminalSnapshotPayload,
|
||||
decodeTerminalStreamFrame,
|
||||
encodeTerminalResizePayload,
|
||||
encodeTerminalSnapshotPayload,
|
||||
encodeTerminalStreamFrame,
|
||||
} from "./index.js";
|
||||
|
||||
describe("terminal binary frames", () => {
|
||||
it("encodes output frames as opcode plus slot plus payload", () => {
|
||||
const payload = new TextEncoder().encode("hello");
|
||||
const encoded = encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
slot: 7,
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(encoded[0]).toBe(TerminalStreamOpcode.Output);
|
||||
expect(encoded[1]).toBe(7);
|
||||
expect(Array.from(encoded.subarray(2))).toEqual(Array.from(payload));
|
||||
|
||||
const decoded = decodeTerminalStreamFrame(encoded);
|
||||
expect(decoded).toEqual({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
slot: 7,
|
||||
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, 0x02]))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects frames without a slot byte", () => {
|
||||
expect(decodeTerminalStreamFrame(new Uint8Array([TerminalStreamOpcode.Output]))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed JSON payloads", () => {
|
||||
const malformed = new TextEncoder().encode("{");
|
||||
|
||||
expect(decodeTerminalResizePayload(malformed)).toBeNull();
|
||||
expect(decodeTerminalSnapshotPayload(malformed)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid resize and snapshot shapes", () => {
|
||||
expect(
|
||||
decodeTerminalResizePayload(
|
||||
new TextEncoder().encode(JSON.stringify({ rows: "24", cols: 80 })),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
decodeTerminalSnapshotPayload(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A" }]],
|
||||
scrollback: [],
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unknown fields in resize and snapshot payloads", () => {
|
||||
expect(
|
||||
decodeTerminalResizePayload(
|
||||
new TextEncoder().encode(JSON.stringify({ rows: 24, cols: 80, extra: true })),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
decodeTerminalSnapshotPayload(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A", extra: true }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 1 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { z } from "zod";
|
||||
import { TerminalStateSchema } from "../messages.js";
|
||||
|
||||
export const TerminalStreamResizeSchema = z
|
||||
.object({
|
||||
rows: z.number().int().positive(),
|
||||
cols: z.number().int().positive(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const TerminalStreamOpcode = {
|
||||
Output: 0x01,
|
||||
Input: 0x02,
|
||||
Resize: 0x03,
|
||||
Snapshot: 0x04,
|
||||
} as const;
|
||||
|
||||
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
|
||||
|
||||
export interface TerminalStreamFrame {
|
||||
opcode: TerminalStreamOpcode;
|
||||
slot: 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 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;
|
||||
slot: number;
|
||||
payload?: Uint8Array | ArrayBuffer | string;
|
||||
}): Uint8Array {
|
||||
const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0);
|
||||
const bytes = new Uint8Array(2 + payload.byteLength);
|
||||
bytes[0] = input.opcode;
|
||||
bytes[1] = input.slot & 0xff;
|
||||
bytes.set(payload, 2);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
|
||||
if (bytes.byteLength < 2) {
|
||||
return null;
|
||||
}
|
||||
const opcode = bytes[0];
|
||||
if (!isTerminalStreamOpcode(opcode)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
opcode,
|
||||
slot: bytes[1],
|
||||
payload: bytes.subarray(2),
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { SessionOutboundMessageSchema } from "./messages.js";
|
||||
import { FileExplorerRequestSchema, SessionOutboundMessageSchema } from "./messages.js";
|
||||
|
||||
function workspaceDescriptor(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -126,3 +126,38 @@ describe("workspace descriptor message compatibility", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("file explorer request compatibility", () => {
|
||||
test("acceptBinary is optional for old clients and accepted for new clients", () => {
|
||||
expect(
|
||||
FileExplorerRequestSchema.parse({
|
||||
type: "file_explorer_request",
|
||||
cwd: "/repo/app",
|
||||
path: "image.png",
|
||||
mode: "file",
|
||||
requestId: "req-old",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "file_explorer_request",
|
||||
cwd: "/repo/app",
|
||||
path: "image.png",
|
||||
mode: "file",
|
||||
requestId: "req-old",
|
||||
});
|
||||
|
||||
expect(
|
||||
FileExplorerRequestSchema.parse({
|
||||
type: "file_explorer_request",
|
||||
cwd: "/repo/app",
|
||||
path: "image.png",
|
||||
mode: "file",
|
||||
requestId: "req-new",
|
||||
acceptBinary: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: "file_explorer_request",
|
||||
requestId: "req-new",
|
||||
acceptBinary: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1505,6 +1505,7 @@ export const FileExplorerRequestSchema = z.object({
|
||||
path: z.string().optional(),
|
||||
mode: z.enum(["list", "file"]),
|
||||
requestId: z.string(),
|
||||
acceptBinary: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ProjectIconRequestSchema = z.object({
|
||||
|
||||
@@ -1,114 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalResizePayload,
|
||||
decodeTerminalSnapshotPayload,
|
||||
decodeTerminalStreamFrame,
|
||||
encodeTerminalResizePayload,
|
||||
encodeTerminalSnapshotPayload,
|
||||
encodeTerminalStreamFrame,
|
||||
} from "./terminal-stream-protocol.js";
|
||||
import * as terminalBinaryFrames from "./binary-frames/index.js";
|
||||
import * as legacyTerminalStreamProtocol from "./terminal-stream-protocol.js";
|
||||
|
||||
describe("terminal stream protocol", () => {
|
||||
it("encodes output frames as opcode plus slot plus payload", () => {
|
||||
it("keeps the old import path compatible with terminal binary frames", () => {
|
||||
expect(legacyTerminalStreamProtocol.TerminalStreamOpcode).toBe(
|
||||
terminalBinaryFrames.TerminalStreamOpcode,
|
||||
);
|
||||
expect(legacyTerminalStreamProtocol.encodeTerminalStreamFrame).toBe(
|
||||
terminalBinaryFrames.encodeTerminalStreamFrame,
|
||||
);
|
||||
expect(legacyTerminalStreamProtocol.decodeTerminalStreamFrame).toBe(
|
||||
terminalBinaryFrames.decodeTerminalStreamFrame,
|
||||
);
|
||||
|
||||
const payload = new TextEncoder().encode("hello");
|
||||
const encoded = encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
const encoded = legacyTerminalStreamProtocol.encodeTerminalStreamFrame({
|
||||
opcode: legacyTerminalStreamProtocol.TerminalStreamOpcode.Output,
|
||||
slot: 7,
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(encoded[0]).toBe(TerminalStreamOpcode.Output);
|
||||
expect(encoded[0]).toBe(terminalBinaryFrames.TerminalStreamOpcode.Output);
|
||||
expect(encoded[1]).toBe(7);
|
||||
expect(Array.from(encoded.subarray(2))).toEqual(Array.from(payload));
|
||||
|
||||
const decoded = decodeTerminalStreamFrame(encoded);
|
||||
const decoded = terminalBinaryFrames.decodeTerminalStreamFrame(encoded);
|
||||
expect(decoded).toEqual({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
opcode: legacyTerminalStreamProtocol.TerminalStreamOpcode.Output,
|
||||
slot: 7,
|
||||
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, 0x02]))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects frames without a slot byte", () => {
|
||||
expect(decodeTerminalStreamFrame(new Uint8Array([TerminalStreamOpcode.Output]))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed JSON payloads", () => {
|
||||
const malformed = new TextEncoder().encode("{");
|
||||
|
||||
expect(decodeTerminalResizePayload(malformed)).toBeNull();
|
||||
expect(decodeTerminalSnapshotPayload(malformed)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid resize and snapshot shapes", () => {
|
||||
expect(
|
||||
decodeTerminalResizePayload(
|
||||
new TextEncoder().encode(JSON.stringify({ rows: "24", cols: 80 })),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
decodeTerminalSnapshotPayload(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A" }]],
|
||||
scrollback: [],
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unknown fields in resize and snapshot payloads", () => {
|
||||
expect(
|
||||
decodeTerminalResizePayload(
|
||||
new TextEncoder().encode(JSON.stringify({ rows: 24, cols: 80, extra: true })),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
decodeTerminalSnapshotPayload(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A", extra: true }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 1 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,131 +1 @@
|
||||
import { z } from "zod";
|
||||
import { TerminalStateSchema } from "./messages.js";
|
||||
|
||||
export const TerminalStreamResizeSchema = z
|
||||
.object({
|
||||
rows: z.number().int().positive(),
|
||||
cols: z.number().int().positive(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const TerminalStreamOpcode = {
|
||||
Output: 0x01,
|
||||
Input: 0x02,
|
||||
Resize: 0x03,
|
||||
Snapshot: 0x04,
|
||||
} as const;
|
||||
|
||||
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
|
||||
|
||||
export interface TerminalStreamFrame {
|
||||
opcode: TerminalStreamOpcode;
|
||||
slot: 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 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;
|
||||
slot: number;
|
||||
payload?: Uint8Array | ArrayBuffer | string;
|
||||
}): Uint8Array {
|
||||
const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0);
|
||||
const bytes = new Uint8Array(2 + payload.byteLength);
|
||||
bytes[0] = input.opcode;
|
||||
bytes[1] = input.slot & 0xff;
|
||||
bytes.set(payload, 2);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
|
||||
if (bytes.byteLength < 2) {
|
||||
return null;
|
||||
}
|
||||
const opcode = bytes[0];
|
||||
if (!isTerminalStreamOpcode(opcode)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
opcode,
|
||||
slot: bytes[1],
|
||||
payload: bytes.subarray(2),
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export * from "./binary-frames/terminal.js";
|
||||
|
||||
Reference in New Issue
Block a user