@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.
@@ -3,13 +3,10 @@
3
3
  // uses Tauri IPC commands to make P2P requests via the server's
4
4
  // app iroh endpoint. no WASM needed.
5
5
 
6
- import type { BlobData, Transport, TransportResponse } from "./transport.js";
7
- import type {
8
- CloseReason,
9
- EventFilter,
10
- JobEvent,
11
- JobStateSnapshot,
12
- } from "./codegen/schema.js";
6
+ import type { BlobData, BlobFetchOptions, Transport, TransportResponse } from "./transport.js";
7
+ import type { BlobProgressCallback } from "./WasmTransport.js";
8
+ import { isTauriRuntime } from "./tauriRuntime.js";
9
+ import type { CloseReason, EventFilter, JobEvent, JobStateSnapshot } from "./codegen/schema.js";
13
10
  import { JobEventsStreamClosed } from "./CharnelLocalTransport.js";
14
11
 
15
12
  // tauri invoke function type
@@ -21,6 +18,124 @@ function cacheKey(blobId: string): string {
21
18
  return `https://blob.local/${blobId}`;
22
19
  }
23
20
 
21
+ /**
22
+ * error thrown by request()/upload methods when a tauri IPC call fails.
23
+ * mirrors sendToRemote.ts's `EnvelopeError` shape so callers can
24
+ * consistently check `.errorType` regardless of which transport produced
25
+ * the failure. `step` distinguishes which part of a two-step upload
26
+ * (local import vs. telling the remote peer to pull) failed.
27
+ */
28
+ export class TransportError extends Error {
29
+ readonly errorType?: string;
30
+ readonly step?: "import" | "remote_trigger";
31
+ /**
32
+ * per-step breakdown for a multi-step fallback chain (e.g.
33
+ * `fetchBlob`'s verified-download -> api-request -> on-demand-blake3
34
+ * sequence). populated only by call sites that actually fall through
35
+ * multiple attempts before failing - absent for single-shot failures.
36
+ */
37
+ readonly attempts?: Array<{ step: string; reason: string }>;
38
+ constructor(
39
+ message: string,
40
+ opts?: {
41
+ errorType?: string;
42
+ step?: "import" | "remote_trigger";
43
+ attempts?: Array<{ step: string; reason: string }>;
44
+ },
45
+ ) {
46
+ super(message);
47
+ this.name = "TransportError";
48
+ this.errorType = opts?.errorType;
49
+ this.step = opts?.step;
50
+ this.attempts = opts?.attempts;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * extract a best-effort `error_type` out of a caught tauri IPC
56
+ * rejection. tauri rejections are sometimes a plain string, sometimes an
57
+ * `Error`, and (once the rust side lands the convention - see
58
+ * docs/error-handling-tasks.md track P0-D) sometimes a short
59
+ * machine-parseable prefix like `"file_not_found: could not open file"`,
60
+ * or a JSON-encoded structured error body forwarded from a remote peer's
61
+ * response. this parses either shape, falling back to the raw message
62
+ * when neither is present - never matches on natural-language message
63
+ * text (the prefix regex only matches a single snake_case token
64
+ * immediately before the colon, so an ordinary sentence like "failed to
65
+ * open file: permission denied" is left alone).
66
+ */
67
+ // P2P `ApiRequest` messages are read in one shot on the remote peer and
68
+ // capped by that peer's configured `federation.max_message_size_mb`
69
+ // (10 MB by default - see grimoire/src/config.rs). the base64 fallback
70
+ // (used here when a blob-pull upload path isn't available for this
71
+ // route, e.g. video uploads today) embeds the whole file directly in
72
+ // that message, inflated ~4/3 by base64 plus a small JSON envelope. we
73
+ // don't know the actual remote's configured cap, so this is a
74
+ // conservative pre-flight check against the documented default with
75
+ // headroom for that overhead - it exists purely to fail fast with a
76
+ // clear message instead of a raw mid-transfer "stream too long"/generic
77
+ // "network error".
78
+ const MAX_BASE64_UPLOAD_BYTES = 7 * 1024 * 1024;
79
+
80
+ function uploadTooLargeError(file: File, path: string): TransportError {
81
+ const mb = (file.size / (1024 * 1024)).toFixed(1);
82
+ const limitMb = (MAX_BASE64_UPLOAD_BYTES / (1024 * 1024)).toFixed(0);
83
+ const kind = path.includes("/video") ? "video uploads" : "uploads of this size";
84
+ return new TransportError(
85
+ `this file is ${mb} MB, too large to upload over this P2P connection (roughly ${limitMb} MB limit) - large ${kind} aren't fully supported over P2P yet`,
86
+ { errorType: "upload_too_large_for_transport" },
87
+ );
88
+ }
89
+
90
+ function extractErrorType(err: unknown): { message: string; errorType?: string } {
91
+ const message = err instanceof Error ? err.message : String(err);
92
+ const trimmed = message.trim();
93
+
94
+ if (trimmed.startsWith("{")) {
95
+ try {
96
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
97
+ const firstError =
98
+ Array.isArray(parsed.errors) && parsed.errors.length > 0
99
+ ? (parsed.errors[0] as Record<string, unknown>)
100
+ : undefined;
101
+ const errorType =
102
+ (typeof firstError?.error_type === "string" && firstError.error_type) ||
103
+ (typeof parsed.error_type === "string" && parsed.error_type) ||
104
+ undefined;
105
+ if (errorType) {
106
+ const detail =
107
+ (typeof firstError?.detail === "string" && firstError.detail) ||
108
+ (typeof parsed.message === "string" && parsed.message) ||
109
+ message;
110
+ return { message: detail, errorType };
111
+ }
112
+ } catch {
113
+ // not valid json, fall through to prefix-token parsing below
114
+ }
115
+ }
116
+
117
+ const prefixMatch = /^([a-z][a-z0-9_]*):\s*(.+)$/s.exec(message);
118
+ if (prefixMatch) {
119
+ return { message: prefixMatch[2], errorType: prefixMatch[1] };
120
+ }
121
+
122
+ return { message };
123
+ }
124
+
125
+ /** run `fn`, retagging any thrown error with which upload step failed. */
126
+ async function tagStep<T>(step: "import" | "remote_trigger", fn: () => Promise<T>): Promise<T> {
127
+ try {
128
+ return await fn();
129
+ } catch (e) {
130
+ if (e instanceof TransportError) {
131
+ throw new TransportError(e.message, { errorType: e.errorType, step });
132
+ }
133
+ const { message, errorType } = extractErrorType(e);
134
+ const stepLabel = step === "import" ? "local import" : "remote trigger";
135
+ throw new TransportError(`${stepLabel} failed: ${message}`, { errorType, step });
136
+ }
137
+ }
138
+
24
139
  // tauri invoke is dynamically imported to avoid bundling in browser builds
25
140
  let invoke: InvokeFn | null = null;
26
141
 
@@ -43,7 +158,7 @@ async function ensureInvoke(): Promise<InvokeFn> {
43
158
  * check if tauri is available
44
159
  */
45
160
  export function isCharnelAvailable(): boolean {
46
- return typeof window !== "undefined" && "__TAURI__" in window;
161
+ return isTauriRuntime();
47
162
  }
48
163
 
49
164
  // default cache name if none provided
@@ -120,11 +235,7 @@ export class CharnelTransport implements Transport {
120
235
  /**
121
236
  * make an API request via P2P
122
237
  */
123
- async request(
124
- method: string,
125
- path: string,
126
- body?: string,
127
- ): Promise<TransportResponse> {
238
+ async request(method: string, path: string, body?: string): Promise<TransportResponse> {
128
239
  const inv = await ensureInvoke();
129
240
 
130
241
  // intentionally no per-error debug log here: the underlying
@@ -133,24 +244,33 @@ export class CharnelTransport implements Transport {
133
244
  // whether to surface the error. logging at this layer fires once
134
245
  // per request per failed peer and drowns the console for any
135
246
  // peer that's temporarily unreachable.
136
- const result = (await inv("p2p_api_call", {
137
- peerAddr: this.peerAddr,
138
- method,
139
- path,
140
- body: body ?? null,
141
- })) as { status: number; body: string };
142
-
143
- return result;
247
+ try {
248
+ const result = (await inv("p2p_api_call", {
249
+ peerAddr: this.peerAddr,
250
+ method,
251
+ path,
252
+ body: body ?? null,
253
+ })) as { status: number; body: string };
254
+
255
+ return result;
256
+ } catch (e) {
257
+ const { message, errorType } = extractErrorType(e);
258
+ throw new TransportError(`p2p request failed: ${message}`, { errorType });
259
+ }
144
260
  }
145
261
 
146
262
  /**
147
263
  * upload via P2P
148
264
  *
149
- * for music uploads, returns an error directing callers to use uploadByPath()
150
- * which uses iroh-blobs pull model (file path -> FsStore import -> remote pull).
151
- * for non-music uploads (images), uses base64 encoding (small enough to be fine).
265
+ * for music/video uploads, uses the iroh-blobs pull model (import bytes ->
266
+ * FsStore -> remote pull) - see uploadByPath() for the filesystem-path variant.
267
+ * for other uploads (images), uses base64 encoding (small enough to be fine).
152
268
  */
153
- async upload(path: string, formData: FormData): Promise<TransportResponse> {
269
+ async upload(
270
+ path: string,
271
+ formData: FormData,
272
+ onProgress?: (loaded: number, total: number) => void,
273
+ ): Promise<TransportResponse> {
154
274
  // extract file from form data
155
275
  const file = formData.get("file") as File | null;
156
276
  if (!file) {
@@ -170,14 +290,16 @@ export class CharnelTransport implements Transport {
170
290
  };
171
291
  }
172
292
 
173
- // for music uploads, import bytes into iroh-blobs store and use the
293
+ // for music/video uploads, import bytes into iroh-blobs store and use the
174
294
  // blake3 pull model (same as uploadByPath but from in-memory bytes).
175
295
  // this supports Android where file picker returns File objects, not paths.
176
- if (path === "/api/upload/music") {
177
- return this.uploadMusicViaBytes(file);
296
+ // the import is already chunked (see uploadMediaViaBytes), so real
297
+ // per-chunk progress is reported here, same as HttpTransport's XHR path.
298
+ if (path === "/api/upload/music" || path === "/api/upload/video") {
299
+ return this.uploadMediaViaBytes(path, file, onProgress);
178
300
  }
179
301
 
180
- // for non-music uploads (images etc), use base64 (small enough)
302
+ // for non-media uploads (images etc), use base64 (small enough)
181
303
  return this.uploadViaBase64(path, file, formData);
182
304
  }
183
305
 
@@ -195,30 +317,31 @@ export class CharnelTransport implements Transport {
195
317
  ): Promise<TransportResponse> {
196
318
  const inv = await ensureInvoke();
197
319
 
198
- // only use iroh-blobs for music uploads
199
- if (path === "/api/upload/music") {
320
+ // only use iroh-blobs for music/video uploads
321
+ if (path === "/api/upload/music" || path === "/api/upload/video") {
200
322
  console.debug("[P2P] uploadByPath: importing blob from", filePath);
201
323
  // import file into local FsStore -> get blake3 hash
202
- const blake3 = (await inv("p2p_import_blob", { filePath })) as string;
324
+ const blake3 = await tagStep(
325
+ "import",
326
+ async () => (await inv("p2p_import_blob", { filePath })) as string,
327
+ );
203
328
  console.debug("[P2P] uploadByPath: imported blob, blake3 =", blake3);
204
329
 
330
+ const defaultName = path === "/api/upload/video" ? "video" : "music";
205
331
  // build request body for the remote peer
206
332
  const body: Record<string, unknown> = {
207
333
  blake3,
208
- filename:
209
- filePath.split("/").pop() || filePath.split("\\").pop() || "music",
334
+ filename: filePath.split("/").pop() || filePath.split("\\").pop() || defaultName,
210
335
  ...metadata,
211
336
  };
212
337
 
213
338
  // tell the remote peer to pull the blob from us
214
- return this.request(
215
- "POST",
216
- "/api/upload/music-by-blake3",
217
- JSON.stringify(body),
339
+ return tagStep("remote_trigger", () =>
340
+ this.request("POST", `${path}-by-blake3`, JSON.stringify(body)),
218
341
  );
219
342
  }
220
343
 
221
- // for non-music uploads, send path + metadata via api_request
344
+ // for non-media uploads, send path + metadata via api_request
222
345
  const body: Record<string, unknown> = {
223
346
  file_path: filePath,
224
347
  ...metadata,
@@ -235,6 +358,10 @@ export class CharnelTransport implements Transport {
235
358
  file: File,
236
359
  formData: FormData,
237
360
  ): Promise<TransportResponse> {
361
+ if (file.size > MAX_BASE64_UPLOAD_BYTES) {
362
+ throw uploadTooLargeError(file, path);
363
+ }
364
+
238
365
  const bytes = new Uint8Array(await file.arrayBuffer());
239
366
  const base64 = bytesToBase64(bytes);
240
367
 
@@ -258,7 +385,7 @@ export class CharnelTransport implements Transport {
258
385
  }
259
386
 
260
387
  /**
261
- * upload music via in-memory bytes using iroh-blobs pull model
388
+ * upload music or video via in-memory bytes using iroh-blobs pull model
262
389
  *
263
390
  * streams the File to the local blobs store in bounded chunks via
264
391
  * p2p_import_begin / p2p_import_chunk / p2p_import_finish, then tells the
@@ -266,45 +393,55 @@ export class CharnelTransport implements Transport {
266
393
  * tauri IPC is JSON-only and a single large base64 payload OOMs the
267
394
  * webview; it also keeps memory bounded on both sides (the receiver
268
395
  * accumulates chunks in a temp file on disk, not in memory).
396
+ *
397
+ * `onProgress`, if given, is called after each chunk finishes uploading
398
+ * with (bytes sent so far, file.size) - this is real, byte-level progress
399
+ * driven by the same chunk loop that does the actual IPC transfer, not an
400
+ * estimate. only covers the local-import phase (client -> local FsStore);
401
+ * the remote peer's own pull afterwards has no progress signal exposed
402
+ * back to this client.
269
403
  */
270
- private async uploadMusicViaBytes(
404
+ private async uploadMediaViaBytes(
405
+ path: string,
271
406
  file: File,
407
+ onProgress?: (loaded: number, total: number) => void,
272
408
  ): Promise<TransportResponse> {
273
409
  const inv = await ensureInvoke();
274
410
 
275
- console.debug("[P2P] uploadMusicViaBytes: streaming file", file.name, file.size, "bytes");
411
+ console.debug("[P2P] uploadMediaViaBytes: streaming file", file.name, file.size, "bytes");
276
412
 
277
413
  // ~4MB raw per chunk -> ~5.5MB base64 per IPC call, well within limits.
278
414
  const CHUNK_SIZE = 4 * 1024 * 1024;
279
415
 
280
- const uploadId = (await inv("p2p_import_begin")) as string;
281
- try {
282
- for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
283
- const slice = file.slice(offset, Math.min(offset + CHUNK_SIZE, file.size));
284
- const chunkBytes = new Uint8Array(await slice.arrayBuffer());
285
- const b64 = bytesToBase64(chunkBytes);
286
- await inv("p2p_import_chunk", { uploadId, data: b64 });
287
- }
288
- } catch (err) {
289
- // best-effort cleanup of the partial temp file on the receiver side.
416
+ const blake3 = await tagStep("import", async () => {
417
+ const uploadId = (await inv("p2p_import_begin")) as string;
290
418
  try {
291
- await inv("p2p_import_abort", { uploadId });
292
- } catch {
293
- // ignore abort failures
419
+ for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
420
+ const slice = file.slice(offset, Math.min(offset + CHUNK_SIZE, file.size));
421
+ const chunkBytes = new Uint8Array(await slice.arrayBuffer());
422
+ const b64 = bytesToBase64(chunkBytes);
423
+ await inv("p2p_import_chunk", { uploadId, data: b64 });
424
+ onProgress?.(Math.min(offset + chunkBytes.length, file.size), file.size);
425
+ }
426
+ } catch (err) {
427
+ // best-effort cleanup of the partial temp file on the receiver side.
428
+ try {
429
+ await inv("p2p_import_abort", { uploadId });
430
+ } catch {
431
+ // ignore abort failures
432
+ }
433
+ throw err;
294
434
  }
295
- throw err;
296
- }
297
435
 
298
- // finalize: import accumulated temp file into the blobs store -> blake3.
299
- const blake3 = (await inv("p2p_import_finish", { uploadId })) as string;
300
- console.debug("[P2P] uploadMusicViaBytes: imported blob, blake3 =", blake3);
436
+ // finalize: import accumulated temp file into the blobs store -> blake3.
437
+ return (await inv("p2p_import_finish", { uploadId })) as string;
438
+ });
439
+ console.debug("[P2P] uploadMediaViaBytes: imported blob, blake3 =", blake3);
301
440
 
302
441
  // tell the remote peer to pull the blob from us
303
442
  const body = { blake3, filename: file.name };
304
- return this.request(
305
- "POST",
306
- "/api/upload/music-by-blake3",
307
- JSON.stringify(body),
443
+ return tagStep("remote_trigger", () =>
444
+ this.request("POST", `${path}-by-blake3`, JSON.stringify(body)),
308
445
  );
309
446
  }
310
447
 
@@ -318,20 +455,37 @@ export class CharnelTransport implements Transport {
318
455
  const inv = await ensureInvoke();
319
456
  const tauri = await import("@tauri-apps/api/core");
320
457
  const onProgress = new tauri.Channel<{ bytes_downloaded: number }>();
458
+ // per-step failure breakdown, attached to the final thrown error so a
459
+ // bug report/log has the full fallback-chain picture, not just the
460
+ // last step's message (see docs/error-handling-tasks.md track P0-E).
461
+ const attempts: Array<{ step: string; reason: string }> = [];
321
462
 
322
463
  if (blake3) {
323
- // blake3 known — use verified iroh-blobs download
324
- const result = (await inv("p2p_fetch_blob_verified", {
325
- peerAddr: this.peerAddr,
326
- blake3Hash: blake3,
327
- onProgress,
328
- })) as { data: string; content_type: string | null; size: number };
329
-
330
- const bytes = base64ToBytes(result.data);
331
- return {
332
- data: bytes,
333
- contentType: result.content_type ?? "audio/mpeg",
334
- };
464
+ // blake3 known — use verified iroh-blobs download. no fallback
465
+ // chain in this branch (no blake3 means we can't retry via
466
+ // api_request/on-demand-blake3 below), but still tag the failure
467
+ // with `attempts` for consistency with the no-blake3 path.
468
+ try {
469
+ const result = (await inv("p2p_fetch_blob_verified", {
470
+ peerAddr: this.peerAddr,
471
+ blake3Hash: blake3,
472
+ onProgress,
473
+ })) as { data: string; content_type: string | null; size: number };
474
+
475
+ const bytes = base64ToBytes(result.data);
476
+ return {
477
+ data: bytes,
478
+ contentType: result.content_type ?? "audio/mpeg",
479
+ };
480
+ } catch (e) {
481
+ const { message, errorType } = extractErrorType(e);
482
+ throw new TransportError(`verified download failed for blob ${blobId}: ${message}`, {
483
+ errorType,
484
+ attempts: [
485
+ { step: "verified_download", reason: errorType ? `${errorType}: ${message}` : message },
486
+ ],
487
+ });
488
+ }
335
489
  }
336
490
 
337
491
  // no blake3 — try api_request to get blob data from database
@@ -345,38 +499,120 @@ export class CharnelTransport implements Transport {
345
499
  const contentType = parsed.data.mime || "application/octet-stream";
346
500
  return { data: bytes, contentType };
347
501
  }
502
+ attempts.push({
503
+ step: "api_request",
504
+ reason: `unexpected response shape (status ${result.status})`,
505
+ });
506
+ } else {
507
+ attempts.push({ step: "api_request", reason: `http ${result.status}` });
348
508
  }
349
509
  } catch (e) {
350
- const errorMessage = e instanceof Error ? e.message : String(e);
510
+ const { message, errorType } = extractErrorType(e);
511
+ const reason = errorType ? `${errorType}: ${message}` : message;
512
+ attempts.push({ step: "api_request", reason });
351
513
  console.warn(
352
- `[CharnelTransport] api blob data request failed, falling back to verified download: ${errorMessage}`,
514
+ `[CharnelTransport] api blob data request failed, falling back to verified download: ${reason}`,
353
515
  );
354
516
  }
355
517
 
356
518
  // fallback: ask the peer to compute blake3, then do verified download
357
- const result = (await inv("p2p_fetch_blob_verified_by_id", {
358
- peerAddr: this.peerAddr,
359
- blobId,
360
- onProgress,
361
- })) as {
362
- data: string;
363
- content_type: string | null;
364
- size: number;
365
- blake3: string;
366
- };
519
+ try {
520
+ const result = (await inv("p2p_fetch_blob_verified_by_id", {
521
+ peerAddr: this.peerAddr,
522
+ blobId,
523
+ onProgress,
524
+ })) as {
525
+ data: string;
526
+ content_type: string | null;
527
+ size: number;
528
+ blake3: string;
529
+ };
530
+
531
+ const bytes = base64ToBytes(result.data);
532
+ return {
533
+ data: bytes,
534
+ contentType: result.content_type ?? "application/octet-stream",
535
+ };
536
+ } catch (e) {
537
+ const { message, errorType } = extractErrorType(e);
538
+ attempts.push({
539
+ step: "on_demand_blake3",
540
+ reason: errorType ? `${errorType}: ${message}` : message,
541
+ });
542
+ throw new TransportError(
543
+ `failed to fetch blob ${blobId} after trying all fallback methods: ${message}`,
544
+ { errorType, attempts },
545
+ );
546
+ }
547
+ }
367
548
 
368
- const bytes = base64ToBytes(result.data);
369
- return {
370
- data: bytes,
371
- contentType: result.content_type ?? "application/octet-stream",
549
+ /**
550
+ * fetch a blob via P2P, reporting download progress.
551
+ *
552
+ * only the verified (blake3) iroh-blobs path can report progress - the
553
+ * rust side streams `bytes_downloaded` over a tauri Channel. without a
554
+ * blake3 there is nothing to subscribe to, so this degrades to a plain
555
+ * `fetchBlob` and a single 100% report at the end.
556
+ *
557
+ * `totalBytes` must be supplied by the caller (blob metadata lookup):
558
+ * the channel only carries a running byte count, not the total.
559
+ */
560
+ async fetchBlobWithProgress(
561
+ blobId: string,
562
+ onProgress: BlobProgressCallback,
563
+ blake3?: string,
564
+ totalBytes?: number,
565
+ mimeType?: string,
566
+ ): Promise<BlobData> {
567
+ const inv = await ensureInvoke();
568
+ const tauri = await import("@tauri-apps/api/core");
569
+ const channel = new tauri.Channel<{ bytes_downloaded: number }>();
570
+ channel.onmessage = (message) => {
571
+ const received = message?.bytes_downloaded ?? 0;
572
+ // a total of 0 keeps the ui on its indeterminate/bouncing state
573
+ // rather than reporting a bogus ratio
574
+ onProgress(received, totalBytes ?? 0);
372
575
  };
576
+
577
+ // most library blobs have no blake3 on the client (only synced/uploaded
578
+ // ones do). the by-id route makes the peer compute it and then streams
579
+ // the same verified download, so it reports real progress too - without
580
+ // this branch a blake3-less blob only ever reported a single 100% tick
581
+ // at the end, which reads as "no progress bar at all".
582
+ const command = blake3 ? "p2p_fetch_blob_verified" : "p2p_fetch_blob_verified_by_id";
583
+ const args = blake3
584
+ ? { peerAddr: this.peerAddr, blake3Hash: blake3, onProgress: channel }
585
+ : { peerAddr: this.peerAddr, blobId, onProgress: channel };
586
+
587
+ try {
588
+ const result = (await inv(command, args)) as {
589
+ data: string;
590
+ content_type: string | null;
591
+ size: number;
592
+ };
593
+
594
+ const bytes = base64ToBytes(result.data);
595
+ onProgress(bytes.byteLength, totalBytes || bytes.byteLength);
596
+ return {
597
+ data: bytes,
598
+ contentType: result.content_type ?? mimeType ?? "application/octet-stream",
599
+ };
600
+ } catch (e) {
601
+ const { message, errorType } = extractErrorType(e);
602
+ throw new TransportError(`verified download failed for blob ${blobId}: ${message}`, {
603
+ errorType,
604
+ attempts: [
605
+ { step: "verified_download", reason: errorType ? `${errorType}: ${message}` : message },
606
+ ],
607
+ });
608
+ }
373
609
  }
374
610
 
375
611
  /**
376
- * get a URL for a blob - caches in Cache API
612
+ * get a URL for a blob - caches in Cache API unless `opts.cache === "skip"`
377
613
  * if blake3 provided, uses verified iroh-blobs download
378
614
  */
379
- async getBlobUrl(blobId: string, blake3?: string): Promise<string> {
615
+ async getBlobUrl(blobId: string, blake3?: string, opts?: BlobFetchOptions): Promise<string> {
380
616
  // check in-memory cache first
381
617
  const cached = urlCache.get(blobId);
382
618
  if (cached) {
@@ -400,11 +636,13 @@ export class CharnelTransport implements Transport {
400
636
  type: blobData.contentType,
401
637
  });
402
638
 
403
- // store in Cache API (HTTP URL key for webkitgtk compatibility)
404
- const response = new Response(blob, {
405
- headers: { "Content-Type": blobData.contentType },
406
- });
407
- await cache.put(cacheKey(blobId), response);
639
+ if (opts?.cache !== "skip") {
640
+ // store in Cache API (HTTP URL key for webkitgtk compatibility)
641
+ const response = new Response(blob, {
642
+ headers: { "Content-Type": blobData.contentType },
643
+ });
644
+ await cache.put(cacheKey(blobId), response);
645
+ }
408
646
 
409
647
  // create object URL
410
648
  const url = URL.createObjectURL(blob);
@@ -412,6 +650,58 @@ export class CharnelTransport implements Transport {
412
650
  return url;
413
651
  }
414
652
 
653
+ /**
654
+ * get a URL for a blob, reporting download progress along the way.
655
+ * same caching behaviour as `getBlobUrl` - a cache hit reports 100%
656
+ * immediately so callers can clear their loading state.
657
+ */
658
+ async getBlobUrlWithProgress(
659
+ blobId: string,
660
+ onProgress: BlobProgressCallback,
661
+ blake3?: string,
662
+ totalBytes?: number,
663
+ mimeType?: string,
664
+ opts?: BlobFetchOptions,
665
+ ): Promise<string> {
666
+ const cached = urlCache.get(blobId);
667
+ if (cached) {
668
+ onProgress(1, 1);
669
+ return cached;
670
+ }
671
+
672
+ const cache = await caches.open(this.cacheName);
673
+ const cachedResponse = await cache.match(cacheKey(blobId));
674
+ if (cachedResponse) {
675
+ const blob = await cachedResponse.blob();
676
+ const url = URL.createObjectURL(blob);
677
+ urlCache.set(blobId, url);
678
+ onProgress(1, 1);
679
+ return url;
680
+ }
681
+
682
+ const blobData = await this.fetchBlobWithProgress(
683
+ blobId,
684
+ onProgress,
685
+ blake3,
686
+ totalBytes,
687
+ mimeType,
688
+ );
689
+ const blob = new Blob([blobData.data.slice().buffer], {
690
+ type: blobData.contentType,
691
+ });
692
+
693
+ if (opts?.cache !== "skip") {
694
+ const response = new Response(blob, {
695
+ headers: { "Content-Type": blobData.contentType },
696
+ });
697
+ await cache.put(cacheKey(blobId), response);
698
+ }
699
+
700
+ const url = URL.createObjectURL(blob);
701
+ urlCache.set(blobId, url);
702
+ return url;
703
+ }
704
+
415
705
  /**
416
706
  * revoke a blob URL
417
707
  */
@@ -476,19 +766,14 @@ export class CharnelTransport implements Transport {
476
766
  return out;
477
767
  }
478
768
 
479
- subscribeJobEvents(
480
- filter?: EventFilter,
481
- signal?: AbortSignal,
482
- ): AsyncIterable<JobEvent> {
769
+ subscribeJobEvents(filter?: EventFilter, signal?: AbortSignal): AsyncIterable<JobEvent> {
483
770
  return charnelRemoteJobEventsIterable(this.peerAddr, filter, signal);
484
771
  }
485
772
  }
486
773
 
487
774
  // frame shape emitted by the rust-side `JobsEventsFrame` — mirrors
488
775
  // `CharnelLocalTransport.ts`.
489
- type JobsEventsFrame =
490
- | { kind: "event"; evt: JobEvent }
491
- | { kind: "closed"; reason: CloseReason };
776
+ type JobsEventsFrame = { kind: "event"; evt: JobEvent } | { kind: "closed"; reason: CloseReason };
492
777
 
493
778
  async function* charnelRemoteJobEventsIterable(
494
779
  peerAddr: string,