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:
Mohamed Boudra
2026-05-02 14:02:27 +08:00
committed by GitHub
parent b6c43088fb
commit bb889dae99
44 changed files with 1630 additions and 436 deletions
@@ -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();
});
});