@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 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
@@ -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
  /**
@@ -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
- export interface DownloadedFile {
3
- bytes: Uint8Array;
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
- constructor(daemon: DaemonClient);
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`: requests a single-use token, then requests the chunked
20
- * transfer, assembling `Begin → Chunk* → End` frames into one buffer.
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
- * Retries exactly once on an expired token. The daemon's TTL runs from the moment it ISSUES a
23
- * token, but the token only becomes usable once its response reaches us — queued behind every
24
- * `Chunk` frame already in flight on this one socket. A large transfer ahead of it can hand us
25
- * a token that is dead on arrival (relay makes this routine: base64-inflated frames, extra
26
- * hop). By the time we see the rejection that backlog has drained, so a second attempt lands on
27
- * a quiet socket; looping further would only spin on a genuinely broken link.
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
- private attemptDownload;
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
- * Upload `bytes` to `path` on the daemon: requests an upload stream, then pushes
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. Resolves once every frame is flushed to the socket.
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, bytes: Uint8Array): Promise<void>;
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 + assembly for binary file downloads plus chunked uploads
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 a token, request the download, await the assembled
8
- * bytes. An upload is the inverse: request an upload stream, then push `Begin → Chunk* → End`
9
- * binary frames the server writes to the target path.
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
- /** The daemon's `startDownload` rejection when a token is unknown, spent, or past its TTL. */
14
- const EXPIRED_TOKEN = "invalid_or_expired_token";
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
- constructor(daemon) {
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
- for (const [stream, pending] of this.pending) {
45
- pending.reject(new Error("file transfer router stopped"));
46
- this.pending.delete(stream);
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`: requests a single-use token, then requests the chunked
51
- * transfer, assembling `Begin → Chunk* → End` frames into one buffer.
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
- * Retries exactly once on an expired token. The daemon's TTL runs from the moment it ISSUES a
54
- * token, but the token only becomes usable once its response reaches us — queued behind every
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 download(path) {
61
- try {
62
- return await this.attemptDownload(path);
63
- }
64
- catch (error) {
65
- if (!(error instanceof Error) || error.message !== EXPIRED_TOKEN)
66
- throw error;
67
- return await this.attemptDownload(path);
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
- const result = new Promise((resolve, reject) => {
84
- this.pending.set(stream, { chunks: [], meta: {}, resolve, reject });
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
- let requestResponse;
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
- requestResponse = await this.daemon.request("file_download_request", { token: tokenResponse.token, stream });
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
- // A timed-out or socket-killed request never streams `End`, so release the id here or it
92
- // stays claimed for the life of the connection and eats the pool.
93
- this.pending.delete(stream);
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
- if (!requestResponse.ok) {
97
- this.pending.delete(stream);
98
- throw new Error(requestResponse.error ?? "download request failed");
426
+ finally {
427
+ for (const cleanup of cleanups)
428
+ cleanup();
429
+ this.uploads.delete(run);
99
430
  }
100
- return result;
101
431
  }
102
- /**
103
- * Upload `bytes` to `path` on the daemon: requests an upload stream, then pushes
104
- * `Begin → Chunk* → End` binary frames the server writes to disk (creating parent dirs,
105
- * overwriting any existing file). The server keys the transfer by `transferId`, which the
106
- * `Begin` frame must echo. Resolves once every frame is flushed to the socket.
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
- const stream = response.stream;
118
- this.daemon.sendBinary(encodeFileTransferFrame({ opcode: "Begin", stream, meta: { transferId } }));
119
- for (let offset = 0; offset < bytes.length; offset += UPLOAD_CHUNK_BYTES) {
120
- const data = bytes.subarray(offset, offset + UPLOAD_CHUNK_BYTES);
121
- this.daemon.sendBinary(encodeFileTransferFrame({ opcode: "Chunk", stream, data }));
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 pending = this.pending.get(frame.stream);
127
- if (!pending)
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
- pending.meta.fileName = frame.meta.fileName;
132
- pending.meta.mimeType = frame.meta.mimeType;
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
- case "Chunk":
135
- pending.chunks.push(frame.data);
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
- case "End":
138
- this.pending.delete(frame.stream);
139
- if (frame.ok) {
140
- pending.resolve({ bytes: concat(pending.chunks), ...pending.meta });
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
- pending.reject(new Error("download failed"));
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.pending.delete(frame.stream);
148
- pending.reject(new Error(frame.message));
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
- /** Subscribe to live `agent_stream` events for this agent. Returns an unsubscribe fn. */
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 {
@@ -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));
@@ -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.117",
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.117",
24
- "@av-pi-studio/relay": "^0.0.117"
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",