@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.
package/dist/daemon-client.d.ts
CHANGED
|
@@ -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
|
/**
|
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,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
|
-
|
|
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 `
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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 `
|
|
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.
|
|
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,
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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);
|
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.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.
|
|
24
|
-
"@av-pi-studio/relay": "^0.0.
|
|
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",
|