@av-pi-studio/client 0.0.117 → 0.0.119
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/daemon-client.d.ts +15 -0
- package/dist/daemon-client.js +7 -2
- package/dist/file-transfer-client.d.ts +131 -17
- package/dist/file-transfer-client.js +453 -82
- package/dist/pistudio-client.d.ts +6 -1
- package/dist/relay-transport.js +6 -1
- package/dist/transport.d.ts +9 -0
- package/dist/transport.js +3 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -92,6 +92,7 @@ interface DaemonClientOptions {
|
|
|
92
92
|
protocolVersion?: number; // defaults to the current protocol version
|
|
93
93
|
appVersion?: string;
|
|
94
94
|
capabilities?: Record<string, boolean>; // CLIENT_CAPS flags to advertise in `hello`
|
|
95
|
+
agentStreams?: "all" | "none"; // "none": the daemon sends no `agent_stream` frames (0.0.119+)
|
|
95
96
|
transport?: Transport; // inject a stub for tests; defaults to native WebSocket
|
|
96
97
|
rpcTimeoutMs?: number; // per-request timeout; never tears down the socket
|
|
97
98
|
now?: () => number; // inject a clock for deterministic tests
|
package/dist/daemon-client.d.ts
CHANGED
|
@@ -21,6 +21,19 @@ export interface DaemonClientOptions {
|
|
|
21
21
|
url: string;
|
|
22
22
|
clientId: string;
|
|
23
23
|
clientType: ClientType;
|
|
24
|
+
/**
|
|
25
|
+
* Free-form platform label sent as `hello.platform` when set (the desktop app sends `"desktop"`
|
|
26
|
+
* with `clientType: "browser"`). Informational; daemons that predate the field ignore it.
|
|
27
|
+
*/
|
|
28
|
+
platform?: string;
|
|
29
|
+
/**
|
|
30
|
+
* `"none"` opens a quiet connection: the daemon (feature `agentStreamsMode`) sends it no
|
|
31
|
+
* `agent_stream` frame, so no `timeline.subscribe` listener ever fires; `agent_update`,
|
|
32
|
+
* `agent_ui_*` and everything else still arrive. Sent as `hello.agentStreams` only when set.
|
|
33
|
+
* Against a daemon without `agentStreamsMode` the field is ignored and every stream arrives, so
|
|
34
|
+
* a consumer must tolerate them. Fixed for the connection; reconnect to change it.
|
|
35
|
+
*/
|
|
36
|
+
agentStreams?: "all" | "none";
|
|
24
37
|
protocolVersion?: number;
|
|
25
38
|
appVersion?: string;
|
|
26
39
|
capabilities?: Record<string, boolean>;
|
|
@@ -67,6 +80,8 @@ export declare class DaemonClient {
|
|
|
67
80
|
get serverId(): string | null;
|
|
68
81
|
get features(): Record<string, unknown>;
|
|
69
82
|
get serverCapabilities(): Record<string, unknown>;
|
|
83
|
+
/** The transport's unsent byte count, or `undefined` when the transport cannot report one. */
|
|
84
|
+
get bufferedAmount(): number | undefined;
|
|
70
85
|
/** True iff the daemon advertised the given `server_info.features.*` flag truthy. */
|
|
71
86
|
hasFeature(flag: string): boolean;
|
|
72
87
|
/**
|
package/dist/daemon-client.js
CHANGED
|
@@ -70,6 +70,10 @@ export class DaemonClient {
|
|
|
70
70
|
get serverCapabilities() {
|
|
71
71
|
return this._serverCapabilities;
|
|
72
72
|
}
|
|
73
|
+
/** The transport's unsent byte count, or `undefined` when the transport cannot report one. */
|
|
74
|
+
get bufferedAmount() {
|
|
75
|
+
return this.transport.bufferedAmount;
|
|
76
|
+
}
|
|
73
77
|
/** True iff the daemon advertised the given `server_info.features.*` flag truthy. */
|
|
74
78
|
hasFeature(flag) {
|
|
75
79
|
return Boolean(this._features[flag]);
|
|
@@ -90,6 +94,8 @@ export class DaemonClient {
|
|
|
90
94
|
type: "hello",
|
|
91
95
|
clientId: this.options.clientId,
|
|
92
96
|
clientType: this.options.clientType,
|
|
97
|
+
platform: this.options.platform,
|
|
98
|
+
agentStreams: this.options.agentStreams,
|
|
93
99
|
protocolVersion: this.options.protocolVersion ?? PROTOCOL_VERSION,
|
|
94
100
|
appVersion: this.options.appVersion,
|
|
95
101
|
capabilities: this.options.capabilities,
|
|
@@ -227,8 +233,7 @@ export class DaemonClient {
|
|
|
227
233
|
*/
|
|
228
234
|
handleBinaryFrame(bytes) {
|
|
229
235
|
const opcodeByte = bytes[0];
|
|
230
|
-
const isFileTransfer = opcodeByte !== undefined &&
|
|
231
|
-
Object.values(FileTransferOpcode).includes(opcodeByte);
|
|
236
|
+
const isFileTransfer = opcodeByte !== undefined && Object.values(FileTransferOpcode).includes(opcodeByte);
|
|
232
237
|
if (isFileTransfer) {
|
|
233
238
|
const frame = tryDecodeFileTransferFrame(bytes);
|
|
234
239
|
if (!frame)
|
|
@@ -1,8 +1,56 @@
|
|
|
1
1
|
import { type DaemonClient } from "./daemon-client.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Upload pacing: `upload` waits before each chunk while the transport reports more than this many
|
|
4
|
+
* unsent bytes, so the socket queue stays within this plus one chunk. 1 MiB (32 chunks) is a
|
|
5
|
+
* chosen value, not a measured one — big enough to keep a fast link busy across a poll interval,
|
|
6
|
+
* small enough that a stalled link holds little memory and a cancel takes effect quickly.
|
|
7
|
+
*/
|
|
8
|
+
export declare const UPLOAD_HIGH_WATER_BYTES: number;
|
|
9
|
+
/** What `FileTransferClient.upload` can stream from. */
|
|
10
|
+
export type UploadSource = Uint8Array | Blob | AsyncIterable<Uint8Array>;
|
|
11
|
+
export interface UploadOptions {
|
|
12
|
+
/** Called after each chunk is queued; `sent` is cumulative, `total` is unknown (`undefined`) for an async iterable. */
|
|
13
|
+
onProgress?(sent: number, total?: number): void;
|
|
14
|
+
/** Aborting sends an `Error` frame for the stream and rejects `upload` with an `AbortError`. */
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
export interface FileTransferClientOptions {
|
|
18
|
+
/** Override {@link UPLOAD_HIGH_WATER_BYTES} (tests). */
|
|
19
|
+
highWaterBytes?: number;
|
|
20
|
+
/** Override the pacing timer (tests); resolves after `ms`. */
|
|
21
|
+
sleep?(ms: number): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/** What a download reports besides its bytes, as announced by the daemon's `Begin` frame. */
|
|
24
|
+
export interface DownloadResult {
|
|
4
25
|
fileName?: string;
|
|
5
26
|
mimeType?: string;
|
|
27
|
+
/** The whole file's size when the transfer began — not the range's. */
|
|
28
|
+
totalBytes: number;
|
|
29
|
+
/** The file's modification time (ms) when the transfer began. */
|
|
30
|
+
mtimeMs: number;
|
|
31
|
+
/** First byte of the range, as served. */
|
|
32
|
+
offset: number;
|
|
33
|
+
/** Bytes in the range, as served (clamped to the end of the file). */
|
|
34
|
+
length: number;
|
|
35
|
+
}
|
|
36
|
+
/** `FileTransferClient.download`'s result: the range's bytes plus {@link DownloadResult}. */
|
|
37
|
+
export interface DownloadedFile extends DownloadResult {
|
|
38
|
+
bytes: Uint8Array;
|
|
39
|
+
}
|
|
40
|
+
export interface DownloadOptions {
|
|
41
|
+
/** First byte to read; default 0. */
|
|
42
|
+
offset?: number;
|
|
43
|
+
/** Bytes to read from `offset`; default (and cap) is to the end of the file. */
|
|
44
|
+
length?: number;
|
|
45
|
+
/** Refuse (`changed`) unless the file still has this size and modification time. */
|
|
46
|
+
ifUnchanged?: {
|
|
47
|
+
size: number;
|
|
48
|
+
mtimeMs: number;
|
|
49
|
+
};
|
|
50
|
+
/** Aborting sends a cancel request for the stream and rejects with an `AbortError`. */
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
/** After each chunk: bytes received so far in this range, and the range's length. */
|
|
53
|
+
onProgress?(received: number, length: number): void;
|
|
6
54
|
}
|
|
7
55
|
export declare class FileTransferClient {
|
|
8
56
|
private readonly daemon;
|
|
@@ -10,31 +58,97 @@ export declare class FileTransferClient {
|
|
|
10
58
|
/** Rotating hand-out point in the one-byte stream id space — ids are a pool, not a counter. */
|
|
11
59
|
private streamCursor;
|
|
12
60
|
private detach;
|
|
13
|
-
|
|
61
|
+
/** Present only while a download is pending: rejects them when the connection leaves `open`. */
|
|
62
|
+
private detachState;
|
|
63
|
+
private readonly uploads;
|
|
64
|
+
private readonly highWaterBytes;
|
|
65
|
+
private readonly sleep;
|
|
66
|
+
constructor(daemon: DaemonClient, options?: FileTransferClientOptions);
|
|
67
|
+
/** Uploads that have not settled yet (0 once every upload has resolved, rejected, or been aborted). */
|
|
68
|
+
get pendingUploads(): number;
|
|
69
|
+
/**
|
|
70
|
+
* Downloads still holding a stream id: in flight, or aborted and waiting for the daemon's last
|
|
71
|
+
* frame. 0 once every download has finished and the daemon is done with its stream.
|
|
72
|
+
*/
|
|
73
|
+
get pendingDownloads(): number;
|
|
14
74
|
/** Begin routing inbound file-transfer frames. Idempotent. */
|
|
15
75
|
start(): void;
|
|
16
|
-
/** Stop routing inbound frames. Any downloads still pending are rejected. */
|
|
76
|
+
/** Stop routing inbound frames. Any downloads and uploads still pending are rejected. */
|
|
17
77
|
stop(): void;
|
|
18
78
|
/**
|
|
19
|
-
* Download the file at `path
|
|
20
|
-
*
|
|
79
|
+
* Download a byte range of the file at `path` into `sink`, which is called once per `Chunk` in
|
|
80
|
+
* wire order. The client keeps no reference to a chunk after the call, so memory stays at about
|
|
81
|
+
* one chunk however large the range is. With no `offset`/`length` the whole file is requested.
|
|
82
|
+
*
|
|
83
|
+
* - **`sink` is synchronous.** Frames are handled in arrival order on the transport's delivery
|
|
84
|
+
* path, so the sink cannot make the daemon wait. A consumer that must wait (writing to disk)
|
|
85
|
+
* has to buffer, or pace itself by asking for ranges one after the other. A sink (or
|
|
86
|
+
* `onProgress`) that throws aborts the download: the daemon is told to stop and this rejects
|
|
87
|
+
* with that error.
|
|
88
|
+
* - **Resolves** after `End` with the range's metadata, taken from the `Begin` frame:
|
|
89
|
+
* `{ fileName?, mimeType?, totalBytes, mtimeMs, offset, length }`. `totalBytes`/`mtimeMs`
|
|
90
|
+
* describe the whole file when the transfer began; keep them to resume with `ifUnchanged`.
|
|
91
|
+
* - **`onProgress(received, length)`** fires after every chunk with the bytes received so far in
|
|
92
|
+
* this range (monotonic) and the range length; it ends at `length`.
|
|
93
|
+
* - **Rejects** with the daemon's error code when the request is refused (`not_found`,
|
|
94
|
+
* `changed`, `stream_in_use`, …), with the `Error` frame's message (`read_failed`, `changed`,
|
|
95
|
+
* `cancelled`) when the transfer fails midway, and when the connection closes or {@link stop}
|
|
96
|
+
* is called. It rejects before sending anything when the daemon did not advertise
|
|
97
|
+
* `fileDownloadRanges`.
|
|
98
|
+
* - **Abort.** `signal` aborting rejects with an `AbortError` and sends
|
|
99
|
+
* `file_download_cancel_request { stream }`. The stream id is held until the daemon has said
|
|
100
|
+
* nothing more will arrive on it (a terminal frame, or a refusal), so a late frame can never
|
|
101
|
+
* reach a later download that reuses the id; frames that do arrive meanwhile are ignored.
|
|
21
102
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
*
|
|
103
|
+
* The stream id is registered BEFORE the request is sent: the daemon answers first and streams
|
|
104
|
+
* right after, and a frame for an unregistered id would be dropped.
|
|
105
|
+
*/
|
|
106
|
+
downloadTo(path: string, sink: (chunk: Uint8Array) => void, options?: DownloadOptions): Promise<DownloadResult>;
|
|
107
|
+
/**
|
|
108
|
+
* Download the file at `path` (or the range in `options`) into one buffer: {@link downloadTo}
|
|
109
|
+
* collecting the chunks. Prefer `downloadTo` for anything large.
|
|
28
110
|
*/
|
|
29
|
-
download(path: string): Promise<DownloadedFile>;
|
|
30
|
-
|
|
111
|
+
download(path: string, options?: DownloadOptions): Promise<DownloadedFile>;
|
|
112
|
+
/** Settle a download for its caller, once. The stream id is released separately. */
|
|
113
|
+
private finish;
|
|
114
|
+
/** Give a download's stream id back — only if it still belongs to `entry`. */
|
|
115
|
+
private release;
|
|
31
116
|
/**
|
|
32
|
-
*
|
|
117
|
+
* Reject a download now and tell the daemon to stop it. The id stays claimed until the daemon
|
|
118
|
+
* has nothing more to send on it: a terminal frame (see `dispatch`), a refused request, or a
|
|
119
|
+
* `not_found` cancel (nothing active — whatever it sent is already behind us on the socket).
|
|
120
|
+
*/
|
|
121
|
+
private abortDownload;
|
|
122
|
+
/** Reject every download and free every id (stop, or the connection is gone). */
|
|
123
|
+
private failDownloads;
|
|
124
|
+
/** Watch the connection only while a download is pending, so an idle client holds no listener. */
|
|
125
|
+
private watchConnection;
|
|
126
|
+
private unwatchConnection;
|
|
127
|
+
/**
|
|
128
|
+
* Upload `source` to `path` on the daemon: requests an upload stream, then pushes
|
|
33
129
|
* `Begin → Chunk* → End` binary frames the server writes to disk (creating parent dirs,
|
|
34
130
|
* overwriting any existing file). The server keys the transfer by `transferId`, which the
|
|
35
|
-
* `Begin` frame must echo.
|
|
131
|
+
* `Begin` frame must echo.
|
|
132
|
+
*
|
|
133
|
+
* - **Streaming.** A `Blob` is sliced and read chunk by chunk and an async iterable is pulled one
|
|
134
|
+
* item at a time, so memory stays at about one chunk (plus the pacing window) regardless of the
|
|
135
|
+
* file size. `total` in `onProgress` is the byte length for a `Uint8Array`/`Blob` and
|
|
136
|
+
* `undefined` for an async iterable.
|
|
137
|
+
* - **Pacing.** Before each chunk it waits while `transport.bufferedAmount` is above
|
|
138
|
+
* {@link UPLOAD_HIGH_WATER_BYTES}; a transport that does not report it is never congested.
|
|
139
|
+
* - **Completion.** With the daemon's `fileTransferAck` feature it resolves when the matching
|
|
140
|
+
* `file_upload_complete` push arrives and rejects with the daemon's error when `ok` is false;
|
|
141
|
+
* there is no timeout on that wait. Without it, it resolves once the transport's buffer has
|
|
142
|
+
* drained after `End` (the bytes have left this machine, not necessarily reached the disk).
|
|
143
|
+
* - **Cancel.** `signal` aborting sends an `Error` frame for the stream (so the daemon frees it)
|
|
144
|
+
* and rejects with an `AbortError`. A dropped connection or {@link stop} rejects too.
|
|
145
|
+
* Whatever way it ends, no listener or stream is left behind.
|
|
36
146
|
*/
|
|
37
|
-
upload(path: string,
|
|
147
|
+
upload(path: string, source: UploadSource, options?: UploadOptions): Promise<void>;
|
|
148
|
+
/** Poll `condition` on a timer until it clears, giving up as soon as the upload has failed. */
|
|
149
|
+
private waitWhile;
|
|
150
|
+
/** Best-effort `Error` frame for an upload stream; a dead connection has nothing to tell. */
|
|
151
|
+
private sendUploadError;
|
|
38
152
|
private dispatch;
|
|
39
153
|
}
|
|
40
154
|
//# sourceMappingURL=file-transfer-client.d.ts.map
|
|
@@ -1,17 +1,104 @@
|
|
|
1
1
|
import { encodeFileTransferFrame, nextFreeSlot, SLOT_SPACE, } from "@av-pi-studio/protocol";
|
|
2
2
|
import { randomId } from "./daemon-client.js";
|
|
3
3
|
/**
|
|
4
|
-
* Client-side demux
|
|
4
|
+
* Client-side demux for binary file downloads plus chunked uploads
|
|
5
5
|
* (features/file-explorer-transfer.md § Binary transfer frames). Mirrors `TerminalStreamRouter`'s
|
|
6
6
|
* inbound-frame-routing shape, but a download is a one-shot request/response rather than a
|
|
7
|
-
* persistent per-slot subscription: request
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* persistent per-slot subscription: request the download by path (and optional byte range), then
|
|
8
|
+
* hand the `Begin → Chunk* → End` frames that follow to a sink or collect them. An upload is the
|
|
9
|
+
* inverse: request an upload stream, then push `Begin → Chunk* → End` binary frames the server
|
|
10
|
+
* writes to the target path.
|
|
10
11
|
*/
|
|
11
12
|
/** Matches the server's default chunk size (`FileTransferService` DEFAULT_CHUNK_BYTES). */
|
|
12
13
|
const UPLOAD_CHUNK_BYTES = 32 * 1024;
|
|
13
|
-
/**
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Upload pacing: `upload` waits before each chunk while the transport reports more than this many
|
|
16
|
+
* unsent bytes, so the socket queue stays within this plus one chunk. 1 MiB (32 chunks) is a
|
|
17
|
+
* chosen value, not a measured one — big enough to keep a fast link busy across a poll interval,
|
|
18
|
+
* small enough that a stalled link holds little memory and a cancel takes effect quickly.
|
|
19
|
+
*/
|
|
20
|
+
export const UPLOAD_HIGH_WATER_BYTES = 1024 * 1024;
|
|
21
|
+
/** How often `upload` re-reads the transport's buffered amount while it is congested. */
|
|
22
|
+
const UPLOAD_POLL_MS = 10;
|
|
23
|
+
/** The message of the rejection when the daemon predates ranged downloads. */
|
|
24
|
+
const RANGES_UNSUPPORTED = "This daemon does not support file downloads by path and range (feature fileDownloadRanges): update the daemon.";
|
|
25
|
+
function createRun() {
|
|
26
|
+
// `new Promise`, not `Promise.withResolvers`: this SDK ships to browsers and React Native
|
|
27
|
+
// runtimes older than ES2024 (and compiles with `lib: ES2023`).
|
|
28
|
+
let reject;
|
|
29
|
+
const promise = new Promise((_, rej) => {
|
|
30
|
+
reject = rej;
|
|
31
|
+
});
|
|
32
|
+
promise.catch(() => { }); // only ever raced: never an unhandled rejection
|
|
33
|
+
let failed;
|
|
34
|
+
return {
|
|
35
|
+
failure: promise,
|
|
36
|
+
fail(error) {
|
|
37
|
+
if (failed)
|
|
38
|
+
return;
|
|
39
|
+
failed = error;
|
|
40
|
+
reject(error);
|
|
41
|
+
},
|
|
42
|
+
check() {
|
|
43
|
+
if (failed)
|
|
44
|
+
throw failed;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function abortError(what) {
|
|
49
|
+
return new DOMException(`The ${what} was aborted`, "AbortError");
|
|
50
|
+
}
|
|
51
|
+
function asError(error) {
|
|
52
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
53
|
+
}
|
|
54
|
+
/** `Begin`'s range metadata, or null when the daemon left a field out. */
|
|
55
|
+
function downloadResult(meta) {
|
|
56
|
+
const { fileName, mimeType, totalBytes, mtimeMs, offset, length } = meta;
|
|
57
|
+
if (totalBytes === undefined ||
|
|
58
|
+
mtimeMs === undefined ||
|
|
59
|
+
offset === undefined ||
|
|
60
|
+
length === undefined) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
...(fileName === undefined ? {} : { fileName }),
|
|
65
|
+
...(mimeType === undefined ? {} : { mimeType }),
|
|
66
|
+
totalBytes,
|
|
67
|
+
mtimeMs,
|
|
68
|
+
offset,
|
|
69
|
+
length,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function isAbortError(error) {
|
|
73
|
+
return error instanceof Error && error.name === "AbortError";
|
|
74
|
+
}
|
|
75
|
+
function defaultSleep(ms) {
|
|
76
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
77
|
+
}
|
|
78
|
+
async function* sliceBytes(bytes) {
|
|
79
|
+
for (let offset = 0; offset < bytes.length; offset += UPLOAD_CHUNK_BYTES) {
|
|
80
|
+
yield bytes.subarray(offset, offset + UPLOAD_CHUNK_BYTES);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Reads one chunk at a time — the whole file is never in memory. */
|
|
84
|
+
async function* sliceBlob(blob) {
|
|
85
|
+
for (let offset = 0; offset < blob.size; offset += UPLOAD_CHUNK_BYTES) {
|
|
86
|
+
yield new Uint8Array(await blob.slice(offset, offset + UPLOAD_CHUNK_BYTES).arrayBuffer());
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Re-cuts whatever the iterable yields into chunks of at most `UPLOAD_CHUNK_BYTES`. */
|
|
90
|
+
async function* splitItems(items) {
|
|
91
|
+
for await (const item of items)
|
|
92
|
+
yield* sliceBytes(item);
|
|
93
|
+
}
|
|
94
|
+
function uploadChunks(source) {
|
|
95
|
+
if (source instanceof Uint8Array)
|
|
96
|
+
return { total: source.length, chunks: sliceBytes(source) };
|
|
97
|
+
if (typeof Blob !== "undefined" && source instanceof Blob) {
|
|
98
|
+
return { total: source.size, chunks: sliceBlob(source) };
|
|
99
|
+
}
|
|
100
|
+
return { total: undefined, chunks: splitItems(source) };
|
|
101
|
+
}
|
|
15
102
|
function concat(chunks) {
|
|
16
103
|
const total = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
17
104
|
const out = new Uint8Array(total);
|
|
@@ -28,8 +115,26 @@ export class FileTransferClient {
|
|
|
28
115
|
/** Rotating hand-out point in the one-byte stream id space — ids are a pool, not a counter. */
|
|
29
116
|
streamCursor = 1;
|
|
30
117
|
detach = null;
|
|
31
|
-
|
|
118
|
+
/** Present only while a download is pending: rejects them when the connection leaves `open`. */
|
|
119
|
+
detachState = null;
|
|
120
|
+
uploads = new Set();
|
|
121
|
+
highWaterBytes;
|
|
122
|
+
sleep;
|
|
123
|
+
constructor(daemon, options = {}) {
|
|
32
124
|
this.daemon = daemon;
|
|
125
|
+
this.highWaterBytes = options.highWaterBytes ?? UPLOAD_HIGH_WATER_BYTES;
|
|
126
|
+
this.sleep = options.sleep ?? defaultSleep;
|
|
127
|
+
}
|
|
128
|
+
/** Uploads that have not settled yet (0 once every upload has resolved, rejected, or been aborted). */
|
|
129
|
+
get pendingUploads() {
|
|
130
|
+
return this.uploads.size;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Downloads still holding a stream id: in flight, or aborted and waiting for the daemon's last
|
|
134
|
+
* frame. 0 once every download has finished and the daemon is done with its stream.
|
|
135
|
+
*/
|
|
136
|
+
get pendingDownloads() {
|
|
137
|
+
return this.pending.size;
|
|
33
138
|
}
|
|
34
139
|
/** Begin routing inbound file-transfer frames. Idempotent. */
|
|
35
140
|
start() {
|
|
@@ -37,115 +142,381 @@ export class FileTransferClient {
|
|
|
37
142
|
return;
|
|
38
143
|
this.detach = this.daemon.onFileTransferFrame((frame) => this.dispatch(frame));
|
|
39
144
|
}
|
|
40
|
-
/** Stop routing inbound frames. Any downloads still pending are rejected. */
|
|
145
|
+
/** Stop routing inbound frames. Any downloads and uploads still pending are rejected. */
|
|
41
146
|
stop() {
|
|
42
147
|
this.detach?.();
|
|
43
148
|
this.detach = null;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
149
|
+
this.failDownloads(new Error("file transfer router stopped"));
|
|
150
|
+
for (const run of this.uploads)
|
|
151
|
+
run.fail(new Error("file transfer router stopped"));
|
|
48
152
|
}
|
|
49
153
|
/**
|
|
50
|
-
* Download the file at `path
|
|
51
|
-
*
|
|
154
|
+
* Download a byte range of the file at `path` into `sink`, which is called once per `Chunk` in
|
|
155
|
+
* wire order. The client keeps no reference to a chunk after the call, so memory stays at about
|
|
156
|
+
* one chunk however large the range is. With no `offset`/`length` the whole file is requested.
|
|
157
|
+
*
|
|
158
|
+
* - **`sink` is synchronous.** Frames are handled in arrival order on the transport's delivery
|
|
159
|
+
* path, so the sink cannot make the daemon wait. A consumer that must wait (writing to disk)
|
|
160
|
+
* has to buffer, or pace itself by asking for ranges one after the other. A sink (or
|
|
161
|
+
* `onProgress`) that throws aborts the download: the daemon is told to stop and this rejects
|
|
162
|
+
* with that error.
|
|
163
|
+
* - **Resolves** after `End` with the range's metadata, taken from the `Begin` frame:
|
|
164
|
+
* `{ fileName?, mimeType?, totalBytes, mtimeMs, offset, length }`. `totalBytes`/`mtimeMs`
|
|
165
|
+
* describe the whole file when the transfer began; keep them to resume with `ifUnchanged`.
|
|
166
|
+
* - **`onProgress(received, length)`** fires after every chunk with the bytes received so far in
|
|
167
|
+
* this range (monotonic) and the range length; it ends at `length`.
|
|
168
|
+
* - **Rejects** with the daemon's error code when the request is refused (`not_found`,
|
|
169
|
+
* `changed`, `stream_in_use`, …), with the `Error` frame's message (`read_failed`, `changed`,
|
|
170
|
+
* `cancelled`) when the transfer fails midway, and when the connection closes or {@link stop}
|
|
171
|
+
* is called. It rejects before sending anything when the daemon did not advertise
|
|
172
|
+
* `fileDownloadRanges`.
|
|
173
|
+
* - **Abort.** `signal` aborting rejects with an `AbortError` and sends
|
|
174
|
+
* `file_download_cancel_request { stream }`. The stream id is held until the daemon has said
|
|
175
|
+
* nothing more will arrive on it (a terminal frame, or a refusal), so a late frame can never
|
|
176
|
+
* reach a later download that reuses the id; frames that do arrive meanwhile are ignored.
|
|
52
177
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* `Chunk` frame already in flight on this one socket. A large transfer ahead of it can hand us
|
|
56
|
-
* a token that is dead on arrival (relay makes this routine: base64-inflated frames, extra
|
|
57
|
-
* hop). By the time we see the rejection that backlog has drained, so a second attempt lands on
|
|
58
|
-
* a quiet socket; looping further would only spin on a genuinely broken link.
|
|
178
|
+
* The stream id is registered BEFORE the request is sent: the daemon answers first and streams
|
|
179
|
+
* right after, and a frame for an unregistered id would be dropped.
|
|
59
180
|
*/
|
|
60
|
-
async
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
async attemptDownload(path) {
|
|
71
|
-
const tokenResponse = await this.daemon.request("file_download_token_request", {
|
|
72
|
-
path,
|
|
73
|
-
});
|
|
74
|
-
if (!tokenResponse.ok || !tokenResponse.token) {
|
|
75
|
-
throw new Error(tokenResponse.error ?? "failed to issue download token");
|
|
76
|
-
}
|
|
181
|
+
async downloadTo(path, sink, options = {}) {
|
|
182
|
+
const { offset, length, ifUnchanged, signal, onProgress } = options;
|
|
183
|
+
if (!this.daemon.hasFeature("fileDownloadRanges"))
|
|
184
|
+
throw new Error(RANGES_UNSUPPORTED);
|
|
185
|
+
if (signal?.aborted)
|
|
186
|
+
throw abortError("download");
|
|
187
|
+
if (this.daemon.state !== "open")
|
|
188
|
+
throw new Error("connection is not open");
|
|
77
189
|
// Stream ids live in a single frame-header byte, so they are recycled as transfers finish;
|
|
78
190
|
// an ever-incrementing counter would emit 256 and be rejected by the codec from then on.
|
|
79
191
|
const stream = nextFreeSlot(this.pending, this.streamCursor);
|
|
80
192
|
if (stream === null)
|
|
81
193
|
throw new Error("no free download stream");
|
|
82
194
|
this.streamCursor = (stream + 1) % SLOT_SPACE;
|
|
83
|
-
|
|
84
|
-
|
|
195
|
+
return await new Promise((resolve, reject) => {
|
|
196
|
+
const entry = {
|
|
197
|
+
sink,
|
|
198
|
+
onProgress,
|
|
199
|
+
info: undefined,
|
|
200
|
+
received: 0,
|
|
201
|
+
progressed: false,
|
|
202
|
+
settled: false,
|
|
203
|
+
detachAbort: null,
|
|
204
|
+
resolve,
|
|
205
|
+
reject,
|
|
206
|
+
};
|
|
207
|
+
this.pending.set(stream, entry);
|
|
208
|
+
this.watchConnection();
|
|
209
|
+
if (signal) {
|
|
210
|
+
const onAbort = () => this.abortDownload(stream, entry, abortError("download"));
|
|
211
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
212
|
+
entry.detachAbort = () => signal.removeEventListener("abort", onAbort);
|
|
213
|
+
}
|
|
214
|
+
// The response only says yes or no: the range metadata arrives in-band on `Begin`, which
|
|
215
|
+
// may be dispatched before this continuation runs.
|
|
216
|
+
this.daemon
|
|
217
|
+
.request("file_download_request", {
|
|
218
|
+
path,
|
|
219
|
+
stream,
|
|
220
|
+
offset,
|
|
221
|
+
length,
|
|
222
|
+
ifUnchanged,
|
|
223
|
+
})
|
|
224
|
+
.then((response) => {
|
|
225
|
+
if (response.ok)
|
|
226
|
+
return;
|
|
227
|
+
// A refused request streams nothing, so nothing else would free the id.
|
|
228
|
+
this.finish(entry, { error: new Error(response.error ?? "download request failed") });
|
|
229
|
+
this.release(stream, entry);
|
|
230
|
+
}, (error) => {
|
|
231
|
+
// Timed out or socket killed: no frame will ever release the id.
|
|
232
|
+
this.finish(entry, { error: asError(error) });
|
|
233
|
+
this.release(stream, entry);
|
|
234
|
+
});
|
|
85
235
|
});
|
|
86
|
-
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Download the file at `path` (or the range in `options`) into one buffer: {@link downloadTo}
|
|
239
|
+
* collecting the chunks. Prefer `downloadTo` for anything large.
|
|
240
|
+
*/
|
|
241
|
+
async download(path, options = {}) {
|
|
242
|
+
const chunks = [];
|
|
243
|
+
const result = await this.downloadTo(path, (chunk) => {
|
|
244
|
+
chunks.push(chunk);
|
|
245
|
+
}, options);
|
|
246
|
+
return { ...result, bytes: concat(chunks) };
|
|
247
|
+
}
|
|
248
|
+
/** Settle a download for its caller, once. The stream id is released separately. */
|
|
249
|
+
finish(entry, outcome) {
|
|
250
|
+
if (entry.settled)
|
|
251
|
+
return;
|
|
252
|
+
entry.settled = true;
|
|
253
|
+
entry.detachAbort?.();
|
|
254
|
+
entry.detachAbort = null;
|
|
255
|
+
if ("error" in outcome)
|
|
256
|
+
entry.reject(outcome.error);
|
|
257
|
+
else
|
|
258
|
+
entry.resolve(outcome.result);
|
|
259
|
+
}
|
|
260
|
+
/** Give a download's stream id back — only if it still belongs to `entry`. */
|
|
261
|
+
release(stream, entry) {
|
|
262
|
+
if (this.pending.get(stream) !== entry)
|
|
263
|
+
return;
|
|
264
|
+
this.pending.delete(stream);
|
|
265
|
+
if (this.pending.size === 0)
|
|
266
|
+
this.unwatchConnection();
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Reject a download now and tell the daemon to stop it. The id stays claimed until the daemon
|
|
270
|
+
* has nothing more to send on it: a terminal frame (see `dispatch`), a refused request, or a
|
|
271
|
+
* `not_found` cancel (nothing active — whatever it sent is already behind us on the socket).
|
|
272
|
+
*/
|
|
273
|
+
abortDownload(stream, entry, error) {
|
|
274
|
+
if (entry.settled)
|
|
275
|
+
return;
|
|
276
|
+
this.finish(entry, { error });
|
|
277
|
+
if (this.daemon.state !== "open") {
|
|
278
|
+
this.release(stream, entry);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
void this.daemon
|
|
282
|
+
.request("file_download_cancel_request", { stream })
|
|
283
|
+
.then((response) => {
|
|
284
|
+
if (!response.ok)
|
|
285
|
+
this.release(stream, entry);
|
|
286
|
+
}, () => this.release(stream, entry));
|
|
287
|
+
}
|
|
288
|
+
/** Reject every download and free every id (stop, or the connection is gone). */
|
|
289
|
+
failDownloads(error) {
|
|
290
|
+
const entries = [...this.pending.values()];
|
|
291
|
+
this.pending.clear();
|
|
292
|
+
this.unwatchConnection();
|
|
293
|
+
for (const entry of entries)
|
|
294
|
+
this.finish(entry, { error });
|
|
295
|
+
}
|
|
296
|
+
/** Watch the connection only while a download is pending, so an idle client holds no listener. */
|
|
297
|
+
watchConnection() {
|
|
298
|
+
if (this.detachState)
|
|
299
|
+
return;
|
|
300
|
+
this.detachState = this.daemon.onStateChange((state) => {
|
|
301
|
+
if (state !== "open")
|
|
302
|
+
this.failDownloads(new Error(`connection ${state} during download`));
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
unwatchConnection() {
|
|
306
|
+
this.detachState?.();
|
|
307
|
+
this.detachState = null;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Upload `source` to `path` on the daemon: requests an upload stream, then pushes
|
|
311
|
+
* `Begin → Chunk* → End` binary frames the server writes to disk (creating parent dirs,
|
|
312
|
+
* overwriting any existing file). The server keys the transfer by `transferId`, which the
|
|
313
|
+
* `Begin` frame must echo.
|
|
314
|
+
*
|
|
315
|
+
* - **Streaming.** A `Blob` is sliced and read chunk by chunk and an async iterable is pulled one
|
|
316
|
+
* item at a time, so memory stays at about one chunk (plus the pacing window) regardless of the
|
|
317
|
+
* file size. `total` in `onProgress` is the byte length for a `Uint8Array`/`Blob` and
|
|
318
|
+
* `undefined` for an async iterable.
|
|
319
|
+
* - **Pacing.** Before each chunk it waits while `transport.bufferedAmount` is above
|
|
320
|
+
* {@link UPLOAD_HIGH_WATER_BYTES}; a transport that does not report it is never congested.
|
|
321
|
+
* - **Completion.** With the daemon's `fileTransferAck` feature it resolves when the matching
|
|
322
|
+
* `file_upload_complete` push arrives and rejects with the daemon's error when `ok` is false;
|
|
323
|
+
* there is no timeout on that wait. Without it, it resolves once the transport's buffer has
|
|
324
|
+
* drained after `End` (the bytes have left this machine, not necessarily reached the disk).
|
|
325
|
+
* - **Cancel.** `signal` aborting sends an `Error` frame for the stream (so the daemon frees it)
|
|
326
|
+
* and rejects with an `AbortError`. A dropped connection or {@link stop} rejects too.
|
|
327
|
+
* Whatever way it ends, no listener or stream is left behind.
|
|
328
|
+
*/
|
|
329
|
+
async upload(path, source, options = {}) {
|
|
330
|
+
const { onProgress, signal } = options;
|
|
331
|
+
if (signal?.aborted)
|
|
332
|
+
throw abortError("upload");
|
|
333
|
+
const run = createRun();
|
|
334
|
+
this.uploads.add(run);
|
|
335
|
+
const cleanups = [];
|
|
336
|
+
let stream;
|
|
337
|
+
let endSent = false;
|
|
338
|
+
let daemonDone = false;
|
|
87
339
|
try {
|
|
88
|
-
|
|
340
|
+
if (signal) {
|
|
341
|
+
const onAbort = () => run.fail(abortError("upload"));
|
|
342
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
343
|
+
cleanups.push(() => signal.removeEventListener("abort", onAbort));
|
|
344
|
+
}
|
|
345
|
+
cleanups.push(this.daemon.onStateChange((state) => {
|
|
346
|
+
if (state !== "open")
|
|
347
|
+
run.fail(new Error(`connection ${state} during upload`));
|
|
348
|
+
}));
|
|
349
|
+
if (this.daemon.state !== "open")
|
|
350
|
+
throw new Error("connection is not open");
|
|
351
|
+
const transferId = randomId();
|
|
352
|
+
const ack = this.daemon.hasFeature("fileTransferAck");
|
|
353
|
+
const request = this.daemon.request("file_upload_request", {
|
|
354
|
+
path,
|
|
355
|
+
transferId,
|
|
356
|
+
...(ack ? { ack: true } : {}),
|
|
357
|
+
});
|
|
358
|
+
let response;
|
|
359
|
+
try {
|
|
360
|
+
response = await Promise.race([request, run.failure]);
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
// The daemon may still answer and hand out a stream nobody will use — give it back.
|
|
364
|
+
void request.then((late) => {
|
|
365
|
+
if (late.ok && late.stream !== undefined)
|
|
366
|
+
this.sendUploadError(late.stream, "client_error");
|
|
367
|
+
}, () => { });
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
if (!response.ok || response.stream === undefined) {
|
|
371
|
+
throw new Error(response.error ?? "upload request failed");
|
|
372
|
+
}
|
|
373
|
+
stream = response.stream;
|
|
374
|
+
// Registered before any frame is sent, so an early failure (or the final ack) cannot be missed.
|
|
375
|
+
let complete;
|
|
376
|
+
const completed = new Promise((resolve) => {
|
|
377
|
+
complete = resolve;
|
|
378
|
+
});
|
|
379
|
+
if (ack) {
|
|
380
|
+
cleanups.push(this.daemon.onSessionMessage((message) => {
|
|
381
|
+
if (message.type !== "file_upload_complete" || message.transferId !== transferId)
|
|
382
|
+
return;
|
|
383
|
+
daemonDone = true; // the daemon freed the stream: nothing left for us to cancel
|
|
384
|
+
if (message.ok)
|
|
385
|
+
complete();
|
|
386
|
+
else
|
|
387
|
+
run.fail(new Error(message.error ?? "upload failed"));
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
this.daemon.sendBinary(encodeFileTransferFrame({ opcode: "Begin", stream, meta: { transferId } }));
|
|
391
|
+
const { total, chunks } = uploadChunks(source);
|
|
392
|
+
const iterator = chunks[Symbol.asyncIterator]();
|
|
393
|
+
cleanups.push(() => {
|
|
394
|
+
void Promise.resolve(iterator.return?.()).catch(() => { });
|
|
395
|
+
});
|
|
396
|
+
let sent = 0;
|
|
397
|
+
for (;;) {
|
|
398
|
+
await this.waitWhile(() => (this.daemon.bufferedAmount ?? 0) > this.highWaterBytes, run);
|
|
399
|
+
const next = iterator.next();
|
|
400
|
+
next.catch(() => { }); // if the race below is lost to a failure, `next` must not go unhandled
|
|
401
|
+
const step = await Promise.race([next, run.failure]);
|
|
402
|
+
if (step.done)
|
|
403
|
+
break;
|
|
404
|
+
run.check();
|
|
405
|
+
this.daemon.sendBinary(encodeFileTransferFrame({ opcode: "Chunk", stream, data: step.value }));
|
|
406
|
+
sent += step.value.length;
|
|
407
|
+
onProgress?.(sent, total);
|
|
408
|
+
}
|
|
409
|
+
if (sent === 0)
|
|
410
|
+
onProgress?.(0, total);
|
|
411
|
+
run.check();
|
|
412
|
+
this.daemon.sendBinary(encodeFileTransferFrame({ opcode: "End", stream, ok: true }));
|
|
413
|
+
endSent = true;
|
|
414
|
+
if (ack)
|
|
415
|
+
await Promise.race([completed, run.failure]);
|
|
416
|
+
else
|
|
417
|
+
await this.waitWhile(() => (this.daemon.bufferedAmount ?? 0) > 0, run);
|
|
89
418
|
}
|
|
90
419
|
catch (error) {
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
420
|
+
// Before `End` the daemon still holds the stream and an open file; tell it to let go.
|
|
421
|
+
if (stream !== undefined && !endSent && !daemonDone) {
|
|
422
|
+
this.sendUploadError(stream, isAbortError(error) ? "client_aborted" : "client_error");
|
|
423
|
+
}
|
|
94
424
|
throw error;
|
|
95
425
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
426
|
+
finally {
|
|
427
|
+
for (const cleanup of cleanups)
|
|
428
|
+
cleanup();
|
|
429
|
+
this.uploads.delete(run);
|
|
99
430
|
}
|
|
100
|
-
return result;
|
|
101
431
|
}
|
|
102
|
-
/**
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
*/
|
|
108
|
-
async upload(path, bytes) {
|
|
109
|
-
const transferId = randomId();
|
|
110
|
-
const response = await this.daemon.request("file_upload_request", {
|
|
111
|
-
path,
|
|
112
|
-
transferId,
|
|
113
|
-
});
|
|
114
|
-
if (!response.ok || response.stream === undefined) {
|
|
115
|
-
throw new Error(response.error ?? "upload request failed");
|
|
432
|
+
/** Poll `condition` on a timer until it clears, giving up as soon as the upload has failed. */
|
|
433
|
+
async waitWhile(condition, run) {
|
|
434
|
+
while (condition()) {
|
|
435
|
+
run.check();
|
|
436
|
+
await Promise.race([this.sleep(UPLOAD_POLL_MS), run.failure]);
|
|
116
437
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
438
|
+
}
|
|
439
|
+
/** Best-effort `Error` frame for an upload stream; a dead connection has nothing to tell. */
|
|
440
|
+
sendUploadError(stream, message) {
|
|
441
|
+
if (this.daemon.state !== "open")
|
|
442
|
+
return;
|
|
443
|
+
try {
|
|
444
|
+
this.daemon.sendBinary(encodeFileTransferFrame({ opcode: "Error", stream, message }));
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
/* the socket died under us — the daemon frees the stream when it notices */
|
|
122
448
|
}
|
|
123
|
-
this.daemon.sendBinary(encodeFileTransferFrame({ opcode: "End", stream, ok: true }));
|
|
124
449
|
}
|
|
125
450
|
dispatch(frame) {
|
|
126
|
-
const
|
|
127
|
-
if (!
|
|
451
|
+
const entry = this.pending.get(frame.stream);
|
|
452
|
+
if (!entry)
|
|
128
453
|
return; // not a download stream we're tracking (e.g. an upload) — ignore
|
|
454
|
+
// Everything below runs synchronously and in arrival order (see `transport.ts`): a frame that
|
|
455
|
+
// is handled later than its successors is a truncated download.
|
|
129
456
|
switch (frame.opcode) {
|
|
130
|
-
case "Begin":
|
|
131
|
-
|
|
132
|
-
|
|
457
|
+
case "Begin": {
|
|
458
|
+
if (entry.settled)
|
|
459
|
+
return;
|
|
460
|
+
const info = downloadResult(frame.meta);
|
|
461
|
+
if (!info) {
|
|
462
|
+
this.abortDownload(frame.stream, entry, new Error("daemon sent a Begin frame without the range"));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
entry.info = info;
|
|
133
466
|
return;
|
|
134
|
-
|
|
135
|
-
|
|
467
|
+
}
|
|
468
|
+
case "Chunk": {
|
|
469
|
+
if (entry.settled)
|
|
470
|
+
return; // aborted: the daemon may still have frames in flight
|
|
471
|
+
const { info } = entry;
|
|
472
|
+
if (!info) {
|
|
473
|
+
this.abortDownload(frame.stream, entry, new Error("daemon sent a Chunk before Begin"));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
entry.sink(frame.data);
|
|
478
|
+
entry.received += frame.data.length;
|
|
479
|
+
entry.progressed = true;
|
|
480
|
+
entry.onProgress?.(entry.received, info.length);
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
this.abortDownload(frame.stream, entry, asError(error));
|
|
484
|
+
}
|
|
136
485
|
return;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
486
|
+
}
|
|
487
|
+
case "End": {
|
|
488
|
+
this.release(frame.stream, entry);
|
|
489
|
+
if (entry.settled)
|
|
490
|
+
return;
|
|
491
|
+
const { info } = entry;
|
|
492
|
+
if (!frame.ok) {
|
|
493
|
+
this.finish(entry, { error: new Error("download failed") });
|
|
494
|
+
}
|
|
495
|
+
else if (!info) {
|
|
496
|
+
this.finish(entry, { error: new Error("download ended before Begin") });
|
|
497
|
+
}
|
|
498
|
+
else if (entry.received !== info.length) {
|
|
499
|
+
this.finish(entry, {
|
|
500
|
+
error: new Error(`download ended after ${entry.received} of ${info.length} bytes`),
|
|
501
|
+
});
|
|
141
502
|
}
|
|
142
503
|
else {
|
|
143
|
-
|
|
504
|
+
try {
|
|
505
|
+
// An empty range sends no `Chunk`; progress still ends at `length`.
|
|
506
|
+
if (!entry.progressed)
|
|
507
|
+
entry.onProgress?.(0, info.length);
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
this.finish(entry, { error: asError(error) });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
this.finish(entry, { result: info });
|
|
144
514
|
}
|
|
145
515
|
return;
|
|
516
|
+
}
|
|
146
517
|
case "Error":
|
|
147
|
-
this.
|
|
148
|
-
|
|
518
|
+
this.release(frame.stream, entry);
|
|
519
|
+
this.finish(entry, { error: new Error(frame.message) });
|
|
149
520
|
return;
|
|
150
521
|
}
|
|
151
522
|
}
|
|
@@ -78,7 +78,12 @@ export interface PiStudioAgentTimelineHandle {
|
|
|
78
78
|
direction?: TimelineDirection;
|
|
79
79
|
limit?: number;
|
|
80
80
|
}): Promise<FetchAgentTimelineResponse>;
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Subscribe to live `agent_stream` events for this agent. Returns an unsubscribe fn. On a
|
|
83
|
+
* connection opened with `agentStreams: "none"` the handler never fires (against a daemon that
|
|
84
|
+
* honours it); against one without `agentStreamsMode` the option is ignored and streams arrive,
|
|
85
|
+
* so a handler must tolerate them.
|
|
86
|
+
*/
|
|
82
87
|
subscribe(handler: PiStudioAgentStreamHandler): () => void;
|
|
83
88
|
}
|
|
84
89
|
export interface PiStudioAgentActions {
|
package/dist/relay-transport.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* `packages/server/src/daemon/relay-transport.ts`'s choice (`{ type: "relay_register", sessionId
|
|
23
23
|
* }`) — the real protocol is unresolved upstream (architecture/relay-e2ee.md § TODO(verify)).
|
|
24
24
|
*/
|
|
25
|
-
import { createClientChannel, deriveRelaySessionId } from "@av-pi-studio/relay";
|
|
25
|
+
import { createClientChannel, deriveRelaySessionId, } from "@av-pi-studio/relay";
|
|
26
26
|
import { reasonString } from "./transport.js";
|
|
27
27
|
/** Build the relay's own `ws://`/`wss://` dial URL from a pairing offer's `relay` info. */
|
|
28
28
|
export function relayDialUrl(relay) {
|
|
@@ -49,6 +49,11 @@ export function createRelayTransport(opts) {
|
|
|
49
49
|
get isOpen() {
|
|
50
50
|
return ws !== null && ws.readyState === 1 && channel !== null && channel.ready;
|
|
51
51
|
},
|
|
52
|
+
// The channel encrypts and writes synchronously, so the relay socket's queue is exactly the
|
|
53
|
+
// (base64-inflated) ciphertext still waiting to leave — the right number to pace against.
|
|
54
|
+
get bufferedAmount() {
|
|
55
|
+
return ws?.bufferedAmount;
|
|
56
|
+
},
|
|
52
57
|
connect(url) {
|
|
53
58
|
return new Promise((resolve, reject) => {
|
|
54
59
|
const create = opts.factory ?? (() => new WebSocket(url));
|
package/dist/transport.d.ts
CHANGED
|
@@ -16,6 +16,13 @@ export interface Transport {
|
|
|
16
16
|
close(code?: number, reason?: string): void;
|
|
17
17
|
/** Whether the transport is currently open and writeable. */
|
|
18
18
|
readonly isOpen: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Bytes accepted by `sendText`/`sendBinary` but not yet handed to the network (the underlying
|
|
21
|
+
* socket's send queue). Optional: a transport that cannot report it leaves it `undefined` and
|
|
22
|
+
* is treated as never congested — `FileTransferClient.upload` then does not pace. Out-of-tree
|
|
23
|
+
* implementations (the desktop app's Rust bridge) may omit it.
|
|
24
|
+
*/
|
|
25
|
+
readonly bufferedAmount?: number;
|
|
19
26
|
onMessage: TransportMessageHandler | null;
|
|
20
27
|
onClose: TransportCloseHandler | null;
|
|
21
28
|
onError: TransportErrorHandler | null;
|
|
@@ -36,6 +43,8 @@ export type AnyWebSocket = {
|
|
|
36
43
|
/** Browsers/RN default this to `"blob"`; Node's `ws` ignores it (always delivers `Buffer`).
|
|
37
44
|
* `connect()` forces `"arraybuffer"` where the property exists — see its comment for why. */
|
|
38
45
|
binaryType?: string;
|
|
46
|
+
/** Bytes queued by `send()` and not yet transmitted (browser/RN/`ws` all report it). */
|
|
47
|
+
readonly bufferedAmount?: number;
|
|
39
48
|
};
|
|
40
49
|
export type WsFactory = (url: string, protocols?: string[]) => AnyWebSocket;
|
|
41
50
|
export declare function reasonString(reason: unknown): string;
|
package/dist/transport.js
CHANGED
|
@@ -23,6 +23,9 @@ export function createWebSocketTransport(factory) {
|
|
|
23
23
|
get isOpen() {
|
|
24
24
|
return ws !== null && ws.readyState === 1; // OPEN
|
|
25
25
|
},
|
|
26
|
+
get bufferedAmount() {
|
|
27
|
+
return ws?.bufferedAmount;
|
|
28
|
+
},
|
|
26
29
|
connect(url) {
|
|
27
30
|
return new Promise((resolve, reject) => {
|
|
28
31
|
const create = factory ?? (() => new WebSocket(url));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@av-pi-studio/client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.119",
|
|
4
4
|
"files": [
|
|
5
5
|
"dist",
|
|
6
6
|
"!dist/**/*.map",
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"clean": "rm -rf dist *.tsbuildinfo"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@av-pi-studio/protocol": "^0.0.
|
|
24
|
-
"@av-pi-studio/relay": "^0.0.
|
|
23
|
+
"@av-pi-studio/protocol": "^0.0.119",
|
|
24
|
+
"@av-pi-studio/relay": "^0.0.119"
|
|
25
25
|
},
|
|
26
26
|
"repository": {
|
|
27
27
|
"type": "git",
|