@av-pi-studio/client 0.0.116 → 0.0.118

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.
@@ -21,6 +21,11 @@ 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;
24
29
  protocolVersion?: number;
25
30
  appVersion?: string;
26
31
  capabilities?: Record<string, boolean>;
@@ -67,6 +72,8 @@ export declare class DaemonClient {
67
72
  get serverId(): string | null;
68
73
  get features(): Record<string, unknown>;
69
74
  get serverCapabilities(): Record<string, unknown>;
75
+ /** The transport's unsent byte count, or `undefined` when the transport cannot report one. */
76
+ get bufferedAmount(): number | undefined;
70
77
  /** True iff the daemon advertised the given `server_info.features.*` flag truthy. */
71
78
  hasFeature(flag: string): boolean;
72
79
  /**
@@ -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,7 @@ export class DaemonClient {
90
94
  type: "hello",
91
95
  clientId: this.options.clientId,
92
96
  clientType: this.options.clientType,
97
+ platform: this.options.platform,
93
98
  protocolVersion: this.options.protocolVersion ?? PROTOCOL_VERSION,
94
99
  appVersion: this.options.appVersion,
95
100
  capabilities: this.options.capabilities,
@@ -227,8 +232,7 @@ export class DaemonClient {
227
232
  */
228
233
  handleBinaryFrame(bytes) {
229
234
  const opcodeByte = bytes[0];
230
- const isFileTransfer = opcodeByte !== undefined &&
231
- Object.values(FileTransferOpcode).includes(opcodeByte);
235
+ const isFileTransfer = opcodeByte !== undefined && Object.values(FileTransferOpcode).includes(opcodeByte);
232
236
  if (isFileTransfer) {
233
237
  const frame = tryDecodeFileTransferFrame(bytes);
234
238
  if (!frame)
@@ -1,4 +1,25 @@
1
1
  import { type DaemonClient } from "./daemon-client.js";
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
+ }
2
23
  export interface DownloadedFile {
3
24
  bytes: Uint8Array;
4
25
  fileName?: string;
@@ -10,10 +31,15 @@ export declare class FileTransferClient {
10
31
  /** Rotating hand-out point in the one-byte stream id space — ids are a pool, not a counter. */
11
32
  private streamCursor;
12
33
  private detach;
13
- constructor(daemon: DaemonClient);
34
+ private readonly uploads;
35
+ private readonly highWaterBytes;
36
+ private readonly sleep;
37
+ constructor(daemon: DaemonClient, options?: FileTransferClientOptions);
38
+ /** Uploads that have not settled yet (0 once every upload has resolved, rejected, or been aborted). */
39
+ get pendingUploads(): number;
14
40
  /** Begin routing inbound file-transfer frames. Idempotent. */
15
41
  start(): void;
16
- /** Stop routing inbound frames. Any downloads still pending are rejected. */
42
+ /** Stop routing inbound frames. Any downloads and uploads still pending are rejected. */
17
43
  stop(): void;
18
44
  /**
19
45
  * Download the file at `path`: requests a single-use token, then requests the chunked
@@ -29,12 +55,30 @@ export declare class FileTransferClient {
29
55
  download(path: string): Promise<DownloadedFile>;
30
56
  private attemptDownload;
31
57
  /**
32
- * Upload `bytes` to `path` on the daemon: requests an upload stream, then pushes
58
+ * Upload `source` to `path` on the daemon: requests an upload stream, then pushes
33
59
  * `Begin → Chunk* → End` binary frames the server writes to disk (creating parent dirs,
34
60
  * 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.
61
+ * `Begin` frame must echo.
62
+ *
63
+ * - **Streaming.** A `Blob` is sliced and read chunk by chunk and an async iterable is pulled one
64
+ * item at a time, so memory stays at about one chunk (plus the pacing window) regardless of the
65
+ * file size. `total` in `onProgress` is the byte length for a `Uint8Array`/`Blob` and
66
+ * `undefined` for an async iterable.
67
+ * - **Pacing.** Before each chunk it waits while `transport.bufferedAmount` is above
68
+ * {@link UPLOAD_HIGH_WATER_BYTES}; a transport that does not report it is never congested.
69
+ * - **Completion.** With the daemon's `fileTransferAck` feature it resolves when the matching
70
+ * `file_upload_complete` push arrives and rejects with the daemon's error when `ok` is false;
71
+ * there is no timeout on that wait. Without it, it resolves once the transport's buffer has
72
+ * drained after `End` (the bytes have left this machine, not necessarily reached the disk).
73
+ * - **Cancel.** `signal` aborting sends an `Error` frame for the stream (so the daemon frees it)
74
+ * and rejects with an `AbortError`. A dropped connection or {@link stop} rejects too.
75
+ * Whatever way it ends, no listener or stream is left behind.
36
76
  */
37
- upload(path: string, bytes: Uint8Array): Promise<void>;
77
+ upload(path: string, source: UploadSource, options?: UploadOptions): Promise<void>;
78
+ /** Poll `condition` on a timer until it clears, giving up as soon as the upload has failed. */
79
+ private waitWhile;
80
+ /** Best-effort `Error` frame for an upload stream; a dead connection has nothing to tell. */
81
+ private sendUploadError;
38
82
  private dispatch;
39
83
  }
40
84
  //# sourceMappingURL=file-transfer-client.d.ts.map
@@ -12,6 +12,71 @@ import { randomId } from "./daemon-client.js";
12
12
  const UPLOAD_CHUNK_BYTES = 32 * 1024;
13
13
  /** The daemon's `startDownload` rejection when a token is unknown, spent, or past its TTL. */
14
14
  const EXPIRED_TOKEN = "invalid_or_expired_token";
15
+ /**
16
+ * Upload pacing: `upload` waits before each chunk while the transport reports more than this many
17
+ * unsent bytes, so the socket queue stays within this plus one chunk. 1 MiB (32 chunks) is a
18
+ * chosen value, not a measured one — big enough to keep a fast link busy across a poll interval,
19
+ * small enough that a stalled link holds little memory and a cancel takes effect quickly.
20
+ */
21
+ export const UPLOAD_HIGH_WATER_BYTES = 1024 * 1024;
22
+ /** How often `upload` re-reads the transport's buffered amount while it is congested. */
23
+ const UPLOAD_POLL_MS = 10;
24
+ function createRun() {
25
+ // `new Promise`, not `Promise.withResolvers`: this SDK ships to browsers and React Native
26
+ // runtimes older than ES2024 (and compiles with `lib: ES2023`).
27
+ let reject;
28
+ const promise = new Promise((_, rej) => {
29
+ reject = rej;
30
+ });
31
+ promise.catch(() => { }); // only ever raced: never an unhandled rejection
32
+ let failed;
33
+ return {
34
+ failure: promise,
35
+ fail(error) {
36
+ if (failed)
37
+ return;
38
+ failed = error;
39
+ reject(error);
40
+ },
41
+ check() {
42
+ if (failed)
43
+ throw failed;
44
+ },
45
+ };
46
+ }
47
+ function abortError() {
48
+ return new DOMException("The upload was aborted", "AbortError");
49
+ }
50
+ function isAbortError(error) {
51
+ return error instanceof Error && error.name === "AbortError";
52
+ }
53
+ function defaultSleep(ms) {
54
+ return new Promise((resolve) => setTimeout(resolve, ms));
55
+ }
56
+ async function* sliceBytes(bytes) {
57
+ for (let offset = 0; offset < bytes.length; offset += UPLOAD_CHUNK_BYTES) {
58
+ yield bytes.subarray(offset, offset + UPLOAD_CHUNK_BYTES);
59
+ }
60
+ }
61
+ /** Reads one chunk at a time — the whole file is never in memory. */
62
+ async function* sliceBlob(blob) {
63
+ for (let offset = 0; offset < blob.size; offset += UPLOAD_CHUNK_BYTES) {
64
+ yield new Uint8Array(await blob.slice(offset, offset + UPLOAD_CHUNK_BYTES).arrayBuffer());
65
+ }
66
+ }
67
+ /** Re-cuts whatever the iterable yields into chunks of at most `UPLOAD_CHUNK_BYTES`. */
68
+ async function* splitItems(items) {
69
+ for await (const item of items)
70
+ yield* sliceBytes(item);
71
+ }
72
+ function uploadChunks(source) {
73
+ if (source instanceof Uint8Array)
74
+ return { total: source.length, chunks: sliceBytes(source) };
75
+ if (typeof Blob !== "undefined" && source instanceof Blob) {
76
+ return { total: source.size, chunks: sliceBlob(source) };
77
+ }
78
+ return { total: undefined, chunks: splitItems(source) };
79
+ }
15
80
  function concat(chunks) {
16
81
  const total = chunks.reduce((sum, c) => sum + c.length, 0);
17
82
  const out = new Uint8Array(total);
@@ -28,8 +93,17 @@ export class FileTransferClient {
28
93
  /** Rotating hand-out point in the one-byte stream id space — ids are a pool, not a counter. */
29
94
  streamCursor = 1;
30
95
  detach = null;
31
- constructor(daemon) {
96
+ uploads = new Set();
97
+ highWaterBytes;
98
+ sleep;
99
+ constructor(daemon, options = {}) {
32
100
  this.daemon = daemon;
101
+ this.highWaterBytes = options.highWaterBytes ?? UPLOAD_HIGH_WATER_BYTES;
102
+ this.sleep = options.sleep ?? defaultSleep;
103
+ }
104
+ /** Uploads that have not settled yet (0 once every upload has resolved, rejected, or been aborted). */
105
+ get pendingUploads() {
106
+ return this.uploads.size;
33
107
  }
34
108
  /** Begin routing inbound file-transfer frames. Idempotent. */
35
109
  start() {
@@ -37,7 +111,7 @@ export class FileTransferClient {
37
111
  return;
38
112
  this.detach = this.daemon.onFileTransferFrame((frame) => this.dispatch(frame));
39
113
  }
40
- /** Stop routing inbound frames. Any downloads still pending are rejected. */
114
+ /** Stop routing inbound frames. Any downloads and uploads still pending are rejected. */
41
115
  stop() {
42
116
  this.detach?.();
43
117
  this.detach = null;
@@ -45,6 +119,8 @@ export class FileTransferClient {
45
119
  pending.reject(new Error("file transfer router stopped"));
46
120
  this.pending.delete(stream);
47
121
  }
122
+ for (const run of this.uploads)
123
+ run.fail(new Error("file transfer router stopped"));
48
124
  }
49
125
  /**
50
126
  * Download the file at `path`: requests a single-use token, then requests the chunked
@@ -100,27 +176,145 @@ export class FileTransferClient {
100
176
  return result;
101
177
  }
102
178
  /**
103
- * Upload `bytes` to `path` on the daemon: requests an upload stream, then pushes
179
+ * Upload `source` to `path` on the daemon: requests an upload stream, then pushes
104
180
  * `Begin → Chunk* → End` binary frames the server writes to disk (creating parent dirs,
105
181
  * 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.
182
+ * `Begin` frame must echo.
183
+ *
184
+ * - **Streaming.** A `Blob` is sliced and read chunk by chunk and an async iterable is pulled one
185
+ * item at a time, so memory stays at about one chunk (plus the pacing window) regardless of the
186
+ * file size. `total` in `onProgress` is the byte length for a `Uint8Array`/`Blob` and
187
+ * `undefined` for an async iterable.
188
+ * - **Pacing.** Before each chunk it waits while `transport.bufferedAmount` is above
189
+ * {@link UPLOAD_HIGH_WATER_BYTES}; a transport that does not report it is never congested.
190
+ * - **Completion.** With the daemon's `fileTransferAck` feature it resolves when the matching
191
+ * `file_upload_complete` push arrives and rejects with the daemon's error when `ok` is false;
192
+ * there is no timeout on that wait. Without it, it resolves once the transport's buffer has
193
+ * drained after `End` (the bytes have left this machine, not necessarily reached the disk).
194
+ * - **Cancel.** `signal` aborting sends an `Error` frame for the stream (so the daemon frees it)
195
+ * and rejects with an `AbortError`. A dropped connection or {@link stop} rejects too.
196
+ * Whatever way it ends, no listener or stream is left behind.
107
197
  */
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");
198
+ async upload(path, source, options = {}) {
199
+ const { onProgress, signal } = options;
200
+ if (signal?.aborted)
201
+ throw abortError();
202
+ const run = createRun();
203
+ this.uploads.add(run);
204
+ const cleanups = [];
205
+ let stream;
206
+ let endSent = false;
207
+ let daemonDone = false;
208
+ try {
209
+ if (signal) {
210
+ const onAbort = () => run.fail(abortError());
211
+ signal.addEventListener("abort", onAbort, { once: true });
212
+ cleanups.push(() => signal.removeEventListener("abort", onAbort));
213
+ }
214
+ cleanups.push(this.daemon.onStateChange((state) => {
215
+ if (state !== "open")
216
+ run.fail(new Error(`connection ${state} during upload`));
217
+ }));
218
+ if (this.daemon.state !== "open")
219
+ throw new Error("connection is not open");
220
+ const transferId = randomId();
221
+ const ack = this.daemon.hasFeature("fileTransferAck");
222
+ const request = this.daemon.request("file_upload_request", {
223
+ path,
224
+ transferId,
225
+ ...(ack ? { ack: true } : {}),
226
+ });
227
+ let response;
228
+ try {
229
+ response = await Promise.race([request, run.failure]);
230
+ }
231
+ catch (error) {
232
+ // The daemon may still answer and hand out a stream nobody will use — give it back.
233
+ void request.then((late) => {
234
+ if (late.ok && late.stream !== undefined)
235
+ this.sendUploadError(late.stream, "client_error");
236
+ }, () => { });
237
+ throw error;
238
+ }
239
+ if (!response.ok || response.stream === undefined) {
240
+ throw new Error(response.error ?? "upload request failed");
241
+ }
242
+ stream = response.stream;
243
+ // Registered before any frame is sent, so an early failure (or the final ack) cannot be missed.
244
+ let complete;
245
+ const completed = new Promise((resolve) => {
246
+ complete = resolve;
247
+ });
248
+ if (ack) {
249
+ cleanups.push(this.daemon.onSessionMessage((message) => {
250
+ if (message.type !== "file_upload_complete" || message.transferId !== transferId)
251
+ return;
252
+ daemonDone = true; // the daemon freed the stream: nothing left for us to cancel
253
+ if (message.ok)
254
+ complete();
255
+ else
256
+ run.fail(new Error(message.error ?? "upload failed"));
257
+ }));
258
+ }
259
+ this.daemon.sendBinary(encodeFileTransferFrame({ opcode: "Begin", stream, meta: { transferId } }));
260
+ const { total, chunks } = uploadChunks(source);
261
+ const iterator = chunks[Symbol.asyncIterator]();
262
+ cleanups.push(() => {
263
+ void Promise.resolve(iterator.return?.()).catch(() => { });
264
+ });
265
+ let sent = 0;
266
+ for (;;) {
267
+ await this.waitWhile(() => (this.daemon.bufferedAmount ?? 0) > this.highWaterBytes, run);
268
+ const next = iterator.next();
269
+ next.catch(() => { }); // if the race below is lost to a failure, `next` must not go unhandled
270
+ const step = await Promise.race([next, run.failure]);
271
+ if (step.done)
272
+ break;
273
+ run.check();
274
+ this.daemon.sendBinary(encodeFileTransferFrame({ opcode: "Chunk", stream, data: step.value }));
275
+ sent += step.value.length;
276
+ onProgress?.(sent, total);
277
+ }
278
+ if (sent === 0)
279
+ onProgress?.(0, total);
280
+ run.check();
281
+ this.daemon.sendBinary(encodeFileTransferFrame({ opcode: "End", stream, ok: true }));
282
+ endSent = true;
283
+ if (ack)
284
+ await Promise.race([completed, run.failure]);
285
+ else
286
+ await this.waitWhile(() => (this.daemon.bufferedAmount ?? 0) > 0, run);
287
+ }
288
+ catch (error) {
289
+ // Before `End` the daemon still holds the stream and an open file; tell it to let go.
290
+ if (stream !== undefined && !endSent && !daemonDone) {
291
+ this.sendUploadError(stream, isAbortError(error) ? "client_aborted" : "client_error");
292
+ }
293
+ throw error;
294
+ }
295
+ finally {
296
+ for (const cleanup of cleanups)
297
+ cleanup();
298
+ this.uploads.delete(run);
299
+ }
300
+ }
301
+ /** Poll `condition` on a timer until it clears, giving up as soon as the upload has failed. */
302
+ async waitWhile(condition, run) {
303
+ while (condition()) {
304
+ run.check();
305
+ await Promise.race([this.sleep(UPLOAD_POLL_MS), run.failure]);
306
+ }
307
+ }
308
+ /** Best-effort `Error` frame for an upload stream; a dead connection has nothing to tell. */
309
+ sendUploadError(stream, message) {
310
+ if (this.daemon.state !== "open")
311
+ return;
312
+ try {
313
+ this.daemon.sendBinary(encodeFileTransferFrame({ opcode: "Error", stream, message }));
116
314
  }
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 }));
315
+ catch {
316
+ /* the socket died under us — the daemon frees the stream when it notices */
122
317
  }
123
- this.daemon.sendBinary(encodeFileTransferFrame({ opcode: "End", stream, ok: true }));
124
318
  }
125
319
  dispatch(frame) {
126
320
  const pending = this.pending.get(frame.stream);
@@ -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.116",
3
+ "version": "0.0.118",
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.116",
24
- "@av-pi-studio/relay": "^0.0.116"
23
+ "@av-pi-studio/protocol": "^0.0.118",
24
+ "@av-pi-studio/relay": "^0.0.118"
25
25
  },
26
26
  "repository": {
27
27
  "type": "git",