mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
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.
155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
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();
|
|
});
|
|
});
|