@av-pi-studio/client 0.0.118 → 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 +8 -0
- package/dist/daemon-client.js +1 -0
- package/dist/file-transfer-client.d.ts +82 -12
- package/dist/file-transfer-client.js +246 -69
- package/dist/pistudio-client.d.ts +6 -1
- 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
|
@@ -26,6 +26,14 @@ export interface DaemonClientOptions {
|
|
|
26
26
|
* with `clientType: "browser"`). Informational; daemons that predate the field ignore it.
|
|
27
27
|
*/
|
|
28
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";
|
|
29
37
|
protocolVersion?: number;
|
|
30
38
|
appVersion?: string;
|
|
31
39
|
capabilities?: Record<string, boolean>;
|
package/dist/daemon-client.js
CHANGED
|
@@ -95,6 +95,7 @@ export class DaemonClient {
|
|
|
95
95
|
clientId: this.options.clientId,
|
|
96
96
|
clientType: this.options.clientType,
|
|
97
97
|
platform: this.options.platform,
|
|
98
|
+
agentStreams: this.options.agentStreams,
|
|
98
99
|
protocolVersion: this.options.protocolVersion ?? PROTOCOL_VERSION,
|
|
99
100
|
appVersion: this.options.appVersion,
|
|
100
101
|
capabilities: this.options.capabilities,
|
|
@@ -20,10 +20,37 @@ export interface FileTransferClientOptions {
|
|
|
20
20
|
/** Override the pacing timer (tests); resolves after `ms`. */
|
|
21
21
|
sleep?(ms: number): Promise<void>;
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/** What a download reports besides its bytes, as announced by the daemon's `Begin` frame. */
|
|
24
|
+
export interface DownloadResult {
|
|
25
25
|
fileName?: string;
|
|
26
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;
|
|
27
54
|
}
|
|
28
55
|
export declare class FileTransferClient {
|
|
29
56
|
private readonly daemon;
|
|
@@ -31,29 +58,72 @@ export declare class FileTransferClient {
|
|
|
31
58
|
/** Rotating hand-out point in the one-byte stream id space — ids are a pool, not a counter. */
|
|
32
59
|
private streamCursor;
|
|
33
60
|
private detach;
|
|
61
|
+
/** Present only while a download is pending: rejects them when the connection leaves `open`. */
|
|
62
|
+
private detachState;
|
|
34
63
|
private readonly uploads;
|
|
35
64
|
private readonly highWaterBytes;
|
|
36
65
|
private readonly sleep;
|
|
37
66
|
constructor(daemon: DaemonClient, options?: FileTransferClientOptions);
|
|
38
67
|
/** Uploads that have not settled yet (0 once every upload has resolved, rejected, or been aborted). */
|
|
39
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;
|
|
40
74
|
/** Begin routing inbound file-transfer frames. Idempotent. */
|
|
41
75
|
start(): void;
|
|
42
76
|
/** Stop routing inbound frames. Any downloads and uploads still pending are rejected. */
|
|
43
77
|
stop(): void;
|
|
44
78
|
/**
|
|
45
|
-
* Download the file at `path
|
|
46
|
-
*
|
|
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.
|
|
47
102
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*
|
|
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.
|
|
110
|
+
*/
|
|
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;
|
|
116
|
+
/**
|
|
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).
|
|
54
120
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
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;
|
|
57
127
|
/**
|
|
58
128
|
* Upload `source` to `path` on the daemon: requests an upload stream, then pushes
|
|
59
129
|
* `Begin → Chunk* → End` binary frames the server writes to disk (creating parent dirs,
|
|
@@ -1,17 +1,16 @@
|
|
|
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
|
-
/** The daemon's `startDownload` rejection when a token is unknown, spent, or past its TTL. */
|
|
14
|
-
const EXPIRED_TOKEN = "invalid_or_expired_token";
|
|
15
14
|
/**
|
|
16
15
|
* Upload pacing: `upload` waits before each chunk while the transport reports more than this many
|
|
17
16
|
* unsent bytes, so the socket queue stays within this plus one chunk. 1 MiB (32 chunks) is a
|
|
@@ -21,6 +20,8 @@ const EXPIRED_TOKEN = "invalid_or_expired_token";
|
|
|
21
20
|
export const UPLOAD_HIGH_WATER_BYTES = 1024 * 1024;
|
|
22
21
|
/** How often `upload` re-reads the transport's buffered amount while it is congested. */
|
|
23
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.";
|
|
24
25
|
function createRun() {
|
|
25
26
|
// `new Promise`, not `Promise.withResolvers`: this SDK ships to browsers and React Native
|
|
26
27
|
// runtimes older than ES2024 (and compiles with `lib: ES2023`).
|
|
@@ -44,8 +45,29 @@ function createRun() {
|
|
|
44
45
|
},
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
|
-
function abortError() {
|
|
48
|
-
return new DOMException(
|
|
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
|
+
};
|
|
49
71
|
}
|
|
50
72
|
function isAbortError(error) {
|
|
51
73
|
return error instanceof Error && error.name === "AbortError";
|
|
@@ -93,6 +115,8 @@ export class FileTransferClient {
|
|
|
93
115
|
/** Rotating hand-out point in the one-byte stream id space — ids are a pool, not a counter. */
|
|
94
116
|
streamCursor = 1;
|
|
95
117
|
detach = null;
|
|
118
|
+
/** Present only while a download is pending: rejects them when the connection leaves `open`. */
|
|
119
|
+
detachState = null;
|
|
96
120
|
uploads = new Set();
|
|
97
121
|
highWaterBytes;
|
|
98
122
|
sleep;
|
|
@@ -105,6 +129,13 @@ export class FileTransferClient {
|
|
|
105
129
|
get pendingUploads() {
|
|
106
130
|
return this.uploads.size;
|
|
107
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;
|
|
138
|
+
}
|
|
108
139
|
/** Begin routing inbound file-transfer frames. Idempotent. */
|
|
109
140
|
start() {
|
|
110
141
|
if (this.detach)
|
|
@@ -115,65 +146,165 @@ export class FileTransferClient {
|
|
|
115
146
|
stop() {
|
|
116
147
|
this.detach?.();
|
|
117
148
|
this.detach = null;
|
|
118
|
-
|
|
119
|
-
pending.reject(new Error("file transfer router stopped"));
|
|
120
|
-
this.pending.delete(stream);
|
|
121
|
-
}
|
|
149
|
+
this.failDownloads(new Error("file transfer router stopped"));
|
|
122
150
|
for (const run of this.uploads)
|
|
123
151
|
run.fail(new Error("file transfer router stopped"));
|
|
124
152
|
}
|
|
125
153
|
/**
|
|
126
|
-
* Download the file at `path
|
|
127
|
-
*
|
|
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.
|
|
128
177
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* `Chunk` frame already in flight on this one socket. A large transfer ahead of it can hand us
|
|
132
|
-
* a token that is dead on arrival (relay makes this routine: base64-inflated frames, extra
|
|
133
|
-
* hop). By the time we see the rejection that backlog has drained, so a second attempt lands on
|
|
134
|
-
* 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.
|
|
135
180
|
*/
|
|
136
|
-
async
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
async attemptDownload(path) {
|
|
147
|
-
const tokenResponse = await this.daemon.request("file_download_token_request", {
|
|
148
|
-
path,
|
|
149
|
-
});
|
|
150
|
-
if (!tokenResponse.ok || !tokenResponse.token) {
|
|
151
|
-
throw new Error(tokenResponse.error ?? "failed to issue download token");
|
|
152
|
-
}
|
|
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");
|
|
153
189
|
// Stream ids live in a single frame-header byte, so they are recycled as transfers finish;
|
|
154
190
|
// an ever-incrementing counter would emit 256 and be rejected by the codec from then on.
|
|
155
191
|
const stream = nextFreeSlot(this.pending, this.streamCursor);
|
|
156
192
|
if (stream === null)
|
|
157
193
|
throw new Error("no free download stream");
|
|
158
194
|
this.streamCursor = (stream + 1) % SLOT_SPACE;
|
|
159
|
-
|
|
160
|
-
|
|
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
|
+
});
|
|
161
235
|
});
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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;
|
|
175
280
|
}
|
|
176
|
-
|
|
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;
|
|
177
308
|
}
|
|
178
309
|
/**
|
|
179
310
|
* Upload `source` to `path` on the daemon: requests an upload stream, then pushes
|
|
@@ -198,7 +329,7 @@ export class FileTransferClient {
|
|
|
198
329
|
async upload(path, source, options = {}) {
|
|
199
330
|
const { onProgress, signal } = options;
|
|
200
331
|
if (signal?.aborted)
|
|
201
|
-
throw abortError();
|
|
332
|
+
throw abortError("upload");
|
|
202
333
|
const run = createRun();
|
|
203
334
|
this.uploads.add(run);
|
|
204
335
|
const cleanups = [];
|
|
@@ -207,7 +338,7 @@ export class FileTransferClient {
|
|
|
207
338
|
let daemonDone = false;
|
|
208
339
|
try {
|
|
209
340
|
if (signal) {
|
|
210
|
-
const onAbort = () => run.fail(abortError());
|
|
341
|
+
const onAbort = () => run.fail(abortError("upload"));
|
|
211
342
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
212
343
|
cleanups.push(() => signal.removeEventListener("abort", onAbort));
|
|
213
344
|
}
|
|
@@ -317,29 +448,75 @@ export class FileTransferClient {
|
|
|
317
448
|
}
|
|
318
449
|
}
|
|
319
450
|
dispatch(frame) {
|
|
320
|
-
const
|
|
321
|
-
if (!
|
|
451
|
+
const entry = this.pending.get(frame.stream);
|
|
452
|
+
if (!entry)
|
|
322
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.
|
|
323
456
|
switch (frame.opcode) {
|
|
324
|
-
case "Begin":
|
|
325
|
-
|
|
326
|
-
|
|
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;
|
|
327
466
|
return;
|
|
328
|
-
|
|
329
|
-
|
|
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
|
+
}
|
|
330
485
|
return;
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
+
});
|
|
335
502
|
}
|
|
336
503
|
else {
|
|
337
|
-
|
|
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 });
|
|
338
514
|
}
|
|
339
515
|
return;
|
|
516
|
+
}
|
|
340
517
|
case "Error":
|
|
341
|
-
this.
|
|
342
|
-
|
|
518
|
+
this.release(frame.stream, entry);
|
|
519
|
+
this.finish(entry, { error: new Error(frame.message) });
|
|
343
520
|
return;
|
|
344
521
|
}
|
|
345
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/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",
|