@freqhole/api-client 0.2.15 → 0.3.2

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/src/transport.ts CHANGED
@@ -4,12 +4,7 @@
4
4
  // FreqholeClient uses a transport to make requests, then handles
5
5
  // Zod validation on top.
6
6
 
7
- import type {
8
- CloseReason,
9
- EventFilter,
10
- JobEvent,
11
- JobStateSnapshot,
12
- } from "./codegen/schema.js";
7
+ import type { CloseReason, EventFilter, JobEvent, JobStateSnapshot } from "./codegen/schema.js";
13
8
 
14
9
  /**
15
10
  * response from a transport request
@@ -27,6 +22,21 @@ export interface BlobData {
27
22
  contentType: string;
28
23
  }
29
24
 
25
+ /**
26
+ * cache policy for a blob fetch.
27
+ *
28
+ * transports that back their blob fetches with the Cache API write every blob
29
+ * they fetch by default. callers that are about to store the bytes somewhere
30
+ * permanent instead (the sync-to-local path writing to OPFS / the grimoire
31
+ * library) must pass `cache: "skip"`, or the same audio ends up stored twice.
32
+ *
33
+ * "skip" suppresses the *write* only — an existing cache entry is still read,
34
+ * since the bytes are already there and are blake3-verified.
35
+ */
36
+ export interface BlobFetchOptions {
37
+ cache?: "write" | "skip";
38
+ }
39
+
30
40
  /**
31
41
  * transport interface - implemented by HttpTransport, AppTransport, WasmTransport
32
42
  */
@@ -44,25 +54,36 @@ export interface Transport {
44
54
  * upload a file via FormData
45
55
  * @param path - API path (e.g., /api/upload/music)
46
56
  * @param formData - FormData with file and metadata
57
+ * @param onProgress - optional callback with (loaded, total) bytes sent so
58
+ * far. only HttpTransport can report real values (via XHR's upload
59
+ * progress event - plain fetch has no cross-browser upload progress
60
+ * API); P2P/tauri transports accept but ignore it since their upload
61
+ * path doesn't stream raw bytes over a trackable request body.
47
62
  * @returns response with status code and body string
48
63
  */
49
- upload(path: string, formData: FormData): Promise<TransportResponse>;
64
+ upload(
65
+ path: string,
66
+ formData: FormData,
67
+ onProgress?: (loaded: number, total: number) => void,
68
+ ): Promise<TransportResponse>;
50
69
 
51
70
  /**
52
71
  * fetch a blob by ID
53
72
  * @param blobId - the blob ID to fetch
54
73
  * @param blake3 - optional blake3 hash for verified streaming via iroh-blobs
74
+ * @param opts - optional cache policy
55
75
  * @returns blob data with content type
56
76
  */
57
- fetchBlob(blobId: string, blake3?: string): Promise<BlobData>;
77
+ fetchBlob(blobId: string, blake3?: string, opts?: BlobFetchOptions): Promise<BlobData>;
58
78
 
59
79
  /**
60
80
  * get a URL for a blob (for <audio>/<img> src)
61
81
  * HTTP transport returns direct URL, P2P transports may need caching
62
82
  * @param blobId - the blob ID to fetch
63
83
  * @param blake3 - optional blake3 hash for verified streaming via iroh-blobs
84
+ * @param opts - optional cache policy
64
85
  */
65
- getBlobUrl(blobId: string, blake3?: string): string | Promise<string>;
86
+ getBlobUrl(blobId: string, blake3?: string, opts?: BlobFetchOptions): string | Promise<string>;
66
87
 
67
88
  /**
68
89
  * get a URL for a blob with progress callback (optional).
@@ -76,6 +97,7 @@ export interface Transport {
76
97
  * @param mimeType - optional content type for the assembled blob (e.g.
77
98
  * the song's media_blob.mime). midden's streaming path doesn't surface
78
99
  * the source mime, so callers should pass it when known.
100
+ * @param opts - optional cache policy
79
101
  */
80
102
  getBlobUrlWithProgress?(
81
103
  blobId: string,
@@ -83,6 +105,7 @@ export interface Transport {
83
105
  blake3?: string,
84
106
  totalBytes?: number,
85
107
  mimeType?: string,
108
+ opts?: BlobFetchOptions,
86
109
  ): Promise<string>;
87
110
 
88
111
  /**
@@ -121,10 +144,7 @@ export interface Transport {
121
144
  * optional `return()` path on the iterator). transports that don't
122
145
  * override use the default polling fallback (see `pollingJobEvents`).
123
146
  */
124
- subscribeJobEvents?(
125
- filter?: EventFilter,
126
- signal?: AbortSignal,
127
- ): AsyncIterable<JobEvent>;
147
+ subscribeJobEvents?(filter?: EventFilter, signal?: AbortSignal): AsyncIterable<JobEvent>;
128
148
  }
129
149
 
130
150
  /**
@@ -172,28 +192,48 @@ export class HttpTransport implements Transport {
172
192
  };
173
193
  }
174
194
 
175
- async upload(path: string, formData: FormData): Promise<TransportResponse> {
195
+ async upload(
196
+ path: string,
197
+ formData: FormData,
198
+ onProgress?: (loaded: number, total: number) => void,
199
+ ): Promise<TransportResponse> {
176
200
  const url = this.baseUrl + path;
177
- const headers: Record<string, string> = {};
178
201
 
179
- // don't set Content-Type - browser sets it with boundary for FormData
180
- if (this.apiKey) {
181
- headers["Authorization"] = `Bearer ${this.apiKey}`;
202
+ // plain fetch has no cross-browser upload-progress event, so only
203
+ // reach for XHR when a caller actually wants progress reported.
204
+ if (!onProgress) {
205
+ const headers: Record<string, string> = {};
206
+ // don't set Content-Type - browser sets it with boundary for FormData
207
+ if (this.apiKey) {
208
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
209
+ }
210
+ const response = await fetch(url, {
211
+ method: "POST",
212
+ headers,
213
+ body: formData,
214
+ credentials: this.apiKey ? "omit" : "include",
215
+ });
216
+ const responseBody = await response.text();
217
+ return { status: response.status, body: responseBody };
182
218
  }
183
219
 
184
- const response = await fetch(url, {
185
- method: "POST",
186
- headers,
187
- body: formData,
188
- credentials: this.apiKey ? "omit" : "include",
220
+ return new Promise<TransportResponse>((resolve, reject) => {
221
+ const xhr = new XMLHttpRequest();
222
+ xhr.open("POST", url);
223
+ if (this.apiKey) {
224
+ xhr.setRequestHeader("Authorization", `Bearer ${this.apiKey}`);
225
+ }
226
+ xhr.withCredentials = !this.apiKey;
227
+ xhr.upload.addEventListener("progress", (e) => {
228
+ if (e.lengthComputable) onProgress(e.loaded, e.total);
229
+ });
230
+ xhr.addEventListener("load", () => {
231
+ resolve({ status: xhr.status, body: xhr.responseText });
232
+ });
233
+ xhr.addEventListener("error", () => reject(new Error("upload failed: network error")));
234
+ xhr.addEventListener("abort", () => reject(new Error("upload failed: aborted")));
235
+ xhr.send(formData);
189
236
  });
190
-
191
- const responseBody = await response.text();
192
-
193
- return {
194
- status: response.status,
195
- body: responseBody,
196
- };
197
237
  }
198
238
 
199
239
  async fetchBlob(blobId: string, _blake3?: string): Promise<BlobData> {
@@ -235,10 +275,7 @@ export class HttpTransport implements Transport {
235
275
  return snapshotJobEventsViaRequest(this, filter);
236
276
  }
237
277
 
238
- subscribeJobEvents(
239
- filter?: EventFilter,
240
- signal?: AbortSignal,
241
- ): AsyncIterable<JobEvent> {
278
+ subscribeJobEvents(filter?: EventFilter, signal?: AbortSignal): AsyncIterable<JobEvent> {
242
279
  return pollingJobEvents(this, filter, signal);
243
280
  }
244
281
  }
@@ -257,11 +294,7 @@ export async function snapshotJobEventsViaRequest(
257
294
  filter?: EventFilter,
258
295
  ): Promise<JobStateSnapshot[]> {
259
296
  const body = JSON.stringify(filter ?? {});
260
- const resp = await transport.request(
261
- "POST",
262
- "/api/jobs/events/snapshot",
263
- body,
264
- );
297
+ const resp = await transport.request("POST", "/api/jobs/events/snapshot", body);
265
298
  if (resp.status >= 400 || resp.status === 0) {
266
299
  throw new Error(`snapshotJobEvents failed: status ${resp.status}`);
267
300
  }