@freqhole/api-client 0.2.15 → 0.3.1

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,14 +3,9 @@
3
3
  // uses midden's MiddenNode to make API requests to peer nodes.
4
4
  // blobs are cached in Cache API for audio playback.
5
5
 
6
- import type { BlobData, Transport, TransportResponse } from "./transport.js";
6
+ import type { BlobData, BlobFetchOptions, Transport, TransportResponse } from "./transport.js";
7
7
  import { snapshotJobEventsViaRequest } from "./transport.js";
8
- import type {
9
- CloseReason,
10
- EventFilter,
11
- JobEvent,
12
- JobStateSnapshot,
13
- } from "./codegen/schema.js";
8
+ import type { CloseReason, EventFilter, JobEvent, JobStateSnapshot } from "./codegen/schema.js";
14
9
  import { JobEventsStreamClosed } from "./CharnelLocalTransport.js";
15
10
 
16
11
  /**
@@ -34,7 +29,11 @@ export interface BiStreamLike {
34
29
  // length-prefixed framing (unused here, but matches the wasm class)
35
30
  write_message?(data: Uint8Array): Promise<void>;
36
31
  read_message?(): Promise<Uint8Array | null>;
37
- // raw byte primitives (read_to_end / write_raw_and_finish exist too)
32
+ // raw byte primitives - used by cenotaph's freqhole/1 api router and
33
+ // the freqhole-player/1 pairing/control handler's write_raw_and_finish
34
+ // response framing (see @freqhole/cenotaph's control/apiRouter.ts).
35
+ read_to_end(max_size: number): Promise<Uint8Array | null>;
36
+ write_raw_and_finish(data: Uint8Array): Promise<void>;
38
37
  close(): void;
39
38
  }
40
39
 
@@ -44,6 +43,12 @@ export interface BiStreamLike {
44
43
  */
45
44
  export interface MiddenNodeLike {
46
45
  node_id(): string;
46
+ // full endpoint address JSON (node_id + relay url + any direct addrs) -
47
+ // lets a dialer skip pkarr/DNS discovery, which otherwise races on a
48
+ // freshly-booted identity (the connecting peer may 404 the pkarr lookup
49
+ // before it's ever published). optional: not every MiddenNodeLike impl
50
+ // (e.g. spume's charnel/tauri stub) has a real iroh endpoint to report.
51
+ node_addr?(): string;
47
52
  secret_key(): Uint8Array;
48
53
  api_request(
49
54
  peer_addr: string,
@@ -73,15 +78,9 @@ export interface MiddenNodeLike {
73
78
  // must be called once for remote peers to pull blobs from this node
74
79
  start_blob_server?(): void;
75
80
  // download blob with iroh-blobs verified streaming - optional
76
- download_verified?(
77
- peer_addr: string,
78
- blake3_hash: string,
79
- ): Promise<Uint8Array>;
81
+ download_verified?(peer_addr: string, blake3_hash: string): Promise<Uint8Array>;
80
82
  // download blob with automatic ensure + retry - optional
81
- download_verified_with_ensure?(
82
- peer_addr: string,
83
- blake3_hash: string,
84
- ): Promise<Uint8Array>;
83
+ download_verified_with_ensure?(peer_addr: string, blake3_hash: string): Promise<Uint8Array>;
85
84
  // download blob by ID with on-demand blake3 computation - optional
86
85
  // returns [Uint8Array, string] but typed as any[] for wasm-bindgen compatibility
87
86
  download_verified_by_id?(peer_addr: string, blob_id: string): Promise<any[]>;
@@ -106,11 +105,7 @@ export interface MiddenNodeLike {
106
105
  // dispatch a freqhole-admin/1 ALPN command to a peer.
107
106
  // returns the grimoire response envelope `{ success, message, data, errors }`.
108
107
  // `args` is a json-encoded string (use "null" for commands with no payload).
109
- proxy_admin?(
110
- peer_addr: string,
111
- command: string,
112
- args: string,
113
- ): Promise<unknown>;
108
+ proxy_admin?(peer_addr: string, command: string, args: string): Promise<unknown>;
114
109
  // tune into a freqhole radio broadcaster (freqhole-radio/1 ALPN).
115
110
  // callbacks:
116
111
  // on_hello(json: string) — HelloMessage as JSON, fires once on connect
@@ -127,6 +122,13 @@ export interface MiddenNodeLike {
127
122
  // open a raw bi-directional stream on an arbitrary ALPN. used for
128
123
  // job events (freqhole-events/1) and other custom protocols.
129
124
  open_bi?(peer_addr: string, alpn: string): Promise<BiStreamLike>;
125
+ // accept the next incoming connection on any registered ALPN (blocks
126
+ // until one arrives; resolves null once the endpoint is closed). used
127
+ // by @freqhole/cenotaph's startAcceptLoop to drive spume's own
128
+ // remote-playback accept mode - see docs/cenotaph-migration-plan.md
129
+ // phase 1. optional because charnel's CharnelTransport has no wasm
130
+ // accept-loop equivalent (yet).
131
+ accept?(): Promise<BiStreamLike | null>;
130
132
  }
131
133
 
132
134
  /**
@@ -173,6 +175,110 @@ function cacheKey(blobId: string): string {
173
175
  return `https://blob.local/${blobId}`;
174
176
  }
175
177
 
178
+ /**
179
+ * error thrown by request()/upload methods when a midden/wasm call
180
+ * fails. mirrors sendToRemote.ts's `EnvelopeError` shape (and
181
+ * CharnelTransport.ts's `TransportError`) so callers can consistently
182
+ * check `.errorType` regardless of which transport produced the failure.
183
+ */
184
+ export class TransportError extends Error {
185
+ readonly errorType?: string;
186
+ /** the original error this was derived from, if any (see uploadViaIrohBlobs). */
187
+ readonly cause?: unknown;
188
+ /**
189
+ * per-step breakdown for a multi-step fallback chain (e.g. `fetchBlob`'s
190
+ * verified-download -> api-request -> on-demand-blake3 -> legacy
191
+ * fetch_blob sequence). populated only by call sites that actually fall
192
+ * through multiple attempts before failing.
193
+ */
194
+ readonly attempts?: Array<{ step: string; reason: string }>;
195
+ constructor(
196
+ message: string,
197
+ opts?: {
198
+ errorType?: string;
199
+ cause?: unknown;
200
+ attempts?: Array<{ step: string; reason: string }>;
201
+ },
202
+ ) {
203
+ super(message);
204
+ this.name = "TransportError";
205
+ this.errorType = opts?.errorType;
206
+ this.cause = opts?.cause;
207
+ this.attempts = opts?.attempts;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * extract a best-effort `error_type` out of a caught midden/wasm
213
+ * rejection. these are sometimes a plain string, sometimes an `Error`,
214
+ * and (once the rust side lands the convention - see
215
+ * docs/error-handling-tasks.md track P0-D) sometimes a short
216
+ * machine-parseable prefix like `"file_not_found: could not open file"`,
217
+ * or a JSON-encoded structured error body forwarded from a remote peer's
218
+ * response. this parses either shape, falling back to the raw message
219
+ * when neither is present - never matches on natural-language message
220
+ * text (the prefix regex only matches a single snake_case token
221
+ * immediately before the colon, so an ordinary sentence like "failed to
222
+ * open file: permission denied" is left alone).
223
+ */
224
+ // P2P `ApiRequest` messages are read in one shot on the remote peer and
225
+ // capped by that peer's configured `federation.max_message_size_mb`
226
+ // (10 MB by default - see grimoire/src/config.rs). the base64 fallback
227
+ // (used here when a blob-pull upload path isn't available for this
228
+ // route, e.g. video uploads today) embeds the whole file directly in
229
+ // that message, inflated ~4/3 by base64 plus a small JSON envelope. we
230
+ // don't know the actual remote's configured cap, so this is a
231
+ // conservative pre-flight check against the documented default with
232
+ // headroom for that overhead - it exists purely to fail fast with a
233
+ // clear message instead of a raw mid-transfer "stream too long"/generic
234
+ // "network error".
235
+ const MAX_BASE64_UPLOAD_BYTES = 7 * 1024 * 1024;
236
+
237
+ function uploadTooLargeError(file: File, path: string): TransportError {
238
+ const mb = (file.size / (1024 * 1024)).toFixed(1);
239
+ const limitMb = (MAX_BASE64_UPLOAD_BYTES / (1024 * 1024)).toFixed(0);
240
+ const kind = path.includes("/video") ? "video uploads" : "uploads of this size";
241
+ return new TransportError(
242
+ `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`,
243
+ { errorType: "upload_too_large_for_transport" },
244
+ );
245
+ }
246
+
247
+ function extractErrorType(err: unknown): { message: string; errorType?: string } {
248
+ const message = err instanceof Error ? err.message : String(err);
249
+ const trimmed = message.trim();
250
+
251
+ if (trimmed.startsWith("{")) {
252
+ try {
253
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
254
+ const firstError =
255
+ Array.isArray(parsed.errors) && parsed.errors.length > 0
256
+ ? (parsed.errors[0] as Record<string, unknown>)
257
+ : undefined;
258
+ const errorType =
259
+ (typeof firstError?.error_type === "string" && firstError.error_type) ||
260
+ (typeof parsed.error_type === "string" && parsed.error_type) ||
261
+ undefined;
262
+ if (errorType) {
263
+ const detail =
264
+ (typeof firstError?.detail === "string" && firstError.detail) ||
265
+ (typeof parsed.message === "string" && parsed.message) ||
266
+ message;
267
+ return { message: detail, errorType };
268
+ }
269
+ } catch {
270
+ // not valid json, fall through to prefix-token parsing below
271
+ }
272
+ }
273
+
274
+ const prefixMatch = /^([a-z][a-z0-9_]*):\s*(.+)$/s.exec(message);
275
+ if (prefixMatch) {
276
+ return { message: prefixMatch[2], errorType: prefixMatch[1] };
277
+ }
278
+
279
+ return { message };
280
+ }
281
+
176
282
  /**
177
283
  * WASM transport - uses midden for P2P connections
178
284
  *
@@ -212,31 +318,41 @@ export class WasmTransport implements Transport {
212
318
  return this.peerAddr;
213
319
  }
214
320
 
215
- async request(
216
- method: string,
217
- path: string,
218
- body?: string,
219
- ): Promise<TransportResponse> {
321
+ async request(method: string, path: string, body?: string): Promise<TransportResponse> {
220
322
  try {
221
- const result = await this.node.api_request(
222
- this.peerAddr,
223
- method,
224
- path,
225
- body ?? null,
226
- );
323
+ // TEMP DEBUG - remove once the first-pair-attempt-fails bug is found
324
+ console.log(`[debug/WasmTransport] request start ${method} ${path} -> ${this.peerAddr}`);
325
+ const result = await this.node.api_request(this.peerAddr, method, path, body ?? null);
326
+ // TEMP DEBUG - remove once the first-pair-attempt-fails bug is found
327
+ console.log(`[debug/WasmTransport] request ok ${method} ${path} status=${result.status}`);
328
+ if (result.status < 200 || result.status >= 300) {
329
+ // TEMP DEBUG - remove once sync-to-local wiring bug is found
330
+ console.log(`[debug/WasmTransport] ${method} ${path} non-2xx body:`, result.body);
331
+ }
227
332
  return {
228
333
  status: result.status,
229
334
  body: result.body,
230
335
  };
231
336
  } catch (e) {
232
337
  // P2P connection errors - rethrow with message that isNetworkError will catch
233
- const errorMessage = e instanceof Error ? e.message : String(e);
234
- console.warn(`[WasmTransport] P2P request failed: ${errorMessage}`);
235
- throw new Error(`connection failed: ${errorMessage}`);
338
+ const { message, errorType } = extractErrorType(e);
339
+ // TEMP DEBUG - remove once sync-to-local wiring bug is found
340
+ console.log(`[debug/WasmTransport] ${method} ${path} threw:`, e);
341
+ console.warn(`[WasmTransport] P2P request failed: ${message}`);
342
+ throw new TransportError(`connection failed: ${message}`, { errorType });
236
343
  }
237
344
  }
238
345
 
239
- async upload(path: string, formData: FormData): Promise<TransportResponse> {
346
+ async upload(
347
+ path: string,
348
+ formData: FormData,
349
+ _onProgress?: (loaded: number, total: number) => void,
350
+ ): Promise<TransportResponse> {
351
+ // no byte-level progress possible here - unlike CharnelTransport's tauri
352
+ // IPC path (which self-chunks and can report per-chunk progress), the
353
+ // midden wasm binding's `import_blob(bytes)` is a single opaque call
354
+ // with no progress callback exposed, and the base64 fallback also sends
355
+ // one JSON request.
240
356
  const file = formData.get("file") as File | null;
241
357
  if (!file) {
242
358
  return {
@@ -255,9 +371,11 @@ export class WasmTransport implements Transport {
255
371
  };
256
372
  }
257
373
 
258
- // for music uploads, use iroh-blobs pull model if available
259
- if (path === "/api/upload/music" && this.node.import_blob) {
260
- return this.uploadViaIrohBlobs(file, formData);
374
+ // music and video uploads both go through the iroh-blobs pull model if
375
+ // available - chunked/verified streaming, no base64/raw-bytes framing.
376
+ const blobPullPaths = ["/api/upload/music", "/api/upload/video"];
377
+ if (blobPullPaths.includes(path) && this.node.import_blob) {
378
+ return this.uploadViaIrohBlobs(path, file, formData);
261
379
  }
262
380
 
263
381
  // fallback: base64 encode and send via api_request (works for image uploads)
@@ -267,11 +385,12 @@ export class WasmTransport implements Transport {
267
385
  /**
268
386
  * upload via iroh-blobs pull model:
269
387
  * 1. import file bytes into local iroh-blobs store (returns blake3 hash)
270
- * 2. tell remote peer the hash via /api/upload/music-by-blake3
388
+ * 2. tell remote peer the hash via `${path}-by-blake3` (e.g. /api/upload/music-by-blake3)
271
389
  * 3. remote peer pulls the blob from us via iroh-blobs verified streaming
272
390
  * 4. release the TempTag so local GC can reclaim the blob
273
391
  */
274
392
  private async uploadViaIrohBlobs(
393
+ path: string,
275
394
  file: File,
276
395
  formData: FormData,
277
396
  ): Promise<TransportResponse> {
@@ -297,9 +416,7 @@ export class WasmTransport implements Transport {
297
416
  }
298
417
 
299
418
  // include associate_with if present
300
- const associateWithStr = formData.get("associate_with") as
301
- | string
302
- | null;
419
+ const associateWithStr = formData.get("associate_with") as string | null;
303
420
  if (associateWithStr) {
304
421
  try {
305
422
  body.associate_with = JSON.parse(associateWithStr);
@@ -308,11 +425,7 @@ export class WasmTransport implements Transport {
308
425
  }
309
426
  }
310
427
 
311
- const response = await this.request(
312
- "POST",
313
- "/api/upload/music-by-blake3",
314
- JSON.stringify(body),
315
- );
428
+ const response = await this.request("POST", `${path}-by-blake3`, JSON.stringify(body));
316
429
  return response;
317
430
  } finally {
318
431
  // release TempTag so local GC can reclaim the blob
@@ -320,7 +433,11 @@ export class WasmTransport implements Transport {
320
433
  }
321
434
  } catch (error) {
322
435
  console.error("[WasmTransport] upload failed:", error);
323
- throw error;
436
+ // preserve the original error (structure, stack, any extra
437
+ // properties) via `cause` rather than flattening it into a plain
438
+ // string - only the message/error_type get normalized here.
439
+ const { message, errorType } = extractErrorType(error);
440
+ throw new TransportError(`upload failed: ${message}`, { errorType, cause: error });
324
441
  }
325
442
  }
326
443
 
@@ -334,18 +451,19 @@ export class WasmTransport implements Transport {
334
451
  file: File,
335
452
  formData: FormData,
336
453
  ): Promise<TransportResponse> {
337
- if (path === "/api/upload/music") {
454
+ if (path === "/api/upload/music" || path === "/api/upload/video") {
338
455
  console.warn(
339
- "[WasmTransport] falling back to base64 upload for music (import_blob not available)",
456
+ `[WasmTransport] falling back to base64 upload for ${path} (import_blob not available)`,
340
457
  );
341
458
  }
342
459
 
460
+ if (file.size > MAX_BASE64_UPLOAD_BYTES) {
461
+ throw uploadTooLargeError(file, path);
462
+ }
463
+
343
464
  const arrayBuffer = await file.arrayBuffer();
344
465
  const base64 = btoa(
345
- new Uint8Array(arrayBuffer).reduce(
346
- (data, byte) => data + String.fromCharCode(byte),
347
- "",
348
- ),
466
+ new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), ""),
349
467
  );
350
468
 
351
469
  const body: Record<string, unknown> = {
@@ -367,46 +485,61 @@ export class WasmTransport implements Transport {
367
485
  return this.request("POST", path, JSON.stringify(body));
368
486
  }
369
487
 
370
- async fetchBlob(blobId: string, blake3?: string): Promise<BlobData> {
488
+ /** write fetched bytes into the Cache API, unless the caller opted out.
489
+ * centralized so every fallback-chain step in `fetchBlob` shares one
490
+ * definition of the cache policy. */
491
+ private async storeInCache(
492
+ cache: Cache,
493
+ blobId: string,
494
+ data: Uint8Array,
495
+ contentType: string,
496
+ opts?: BlobFetchOptions,
497
+ ): Promise<void> {
498
+ if (opts?.cache === "skip") return;
499
+ const arrayBuffer = data.buffer.slice(
500
+ data.byteOffset,
501
+ data.byteOffset + data.byteLength,
502
+ ) as ArrayBuffer;
503
+ const response = new Response(arrayBuffer, {
504
+ headers: { "Content-Type": contentType },
505
+ });
506
+ await cache.put(cacheKey(blobId), response);
507
+ }
508
+
509
+ async fetchBlob(blobId: string, blake3?: string, opts?: BlobFetchOptions): Promise<BlobData> {
371
510
  // check Cache API first
372
511
  const cache = await caches.open(this.cacheName);
373
512
  const cached = await cache.match(cacheKey(blobId));
374
513
 
375
514
  if (cached) {
376
515
  const data = new Uint8Array(await cached.arrayBuffer());
377
- const contentType =
378
- cached.headers.get("Content-Type") || "application/octet-stream";
516
+ const contentType = cached.headers.get("Content-Type") || "application/octet-stream";
379
517
  return { data, contentType };
380
518
  }
381
519
 
520
+ // per-step failure breakdown, attached to the final thrown error so a
521
+ // bug report/log has the full fallback-chain picture, not just the
522
+ // last step's message.
523
+ const attempts: Array<{ step: string; reason: string }> = [];
524
+
382
525
  // if blake3 is provided, try iroh-blobs verified download
383
526
  // prefer download_verified_with_ensure (handles on-demand loading)
384
527
  // fall back to download_verified if that's not available
385
528
  if (blake3) {
386
- const downloadFn =
387
- this.node.download_verified_with_ensure ?? this.node.download_verified;
529
+ const downloadFn = this.node.download_verified_with_ensure ?? this.node.download_verified;
388
530
 
389
531
  if (downloadFn) {
390
532
  try {
391
533
  const data = await downloadFn.call(this.node, this.peerAddr, blake3);
392
534
  const contentType = "audio/mpeg"; // iroh-blobs doesn't track content type, assume audio
393
535
 
394
- // cache for future use
395
- const arrayBuffer = data.buffer.slice(
396
- data.byteOffset,
397
- data.byteOffset + data.byteLength,
398
- ) as ArrayBuffer;
399
- const response = new Response(arrayBuffer, {
400
- headers: { "Content-Type": contentType },
401
- });
402
- await cache.put(cacheKey(blobId), response);
536
+ await this.storeInCache(cache, blobId, data, contentType, opts);
403
537
 
404
538
  return { data, contentType };
405
539
  } catch (e) {
406
540
  const errorMessage = e instanceof Error ? e.message : String(e);
407
- console.warn(
408
- `[WasmTransport] verified download failed, falling back: ${errorMessage}`,
409
- );
541
+ attempts.push({ step: "verified_download", reason: errorMessage });
542
+ console.warn(`[WasmTransport] verified download failed, falling back: ${errorMessage}`);
410
543
  // fall through to api fetch
411
544
  }
412
545
  }
@@ -427,50 +560,37 @@ export class WasmTransport implements Transport {
427
560
  const data = base64ToBytes(parsed.data.data);
428
561
  const contentType = parsed.data.mime || "application/octet-stream";
429
562
 
430
- // cache for future use with correct content type
431
- const arrayBuffer = data.buffer.slice(
432
- data.byteOffset,
433
- data.byteOffset + data.byteLength,
434
- ) as ArrayBuffer;
435
- const response = new Response(arrayBuffer, {
436
- headers: { "Content-Type": contentType },
437
- });
438
- await cache.put(cacheKey(blobId), response);
563
+ await this.storeInCache(cache, blobId, data, contentType, opts);
439
564
 
440
565
  return { data, contentType };
441
566
  }
567
+ attempts.push({
568
+ step: "api_request",
569
+ reason: `unexpected response shape (status ${result.status})`,
570
+ });
571
+ } else {
572
+ attempts.push({ step: "api_request", reason: `http ${result.status}` });
442
573
  }
443
574
  } catch (e) {
444
575
  const errorMessage = e instanceof Error ? e.message : String(e);
445
- console.warn(
446
- `[WasmTransport] api blob data request failed, falling back: ${errorMessage}`,
447
- );
576
+ attempts.push({ step: "api_request", reason: errorMessage });
577
+ console.warn(`[WasmTransport] api blob data request failed, falling back: ${errorMessage}`);
448
578
  }
449
579
 
450
580
  // fallback: try on-demand blake3 computation + verified download via iroh-blobs
451
581
  if (!blake3 && this.node.download_verified_by_id) {
452
582
  try {
453
- const result = await this.node.download_verified_by_id(
454
- this.peerAddr,
455
- blobId,
456
- );
583
+ const result = await this.node.download_verified_by_id(this.peerAddr, blobId);
457
584
  const data = result[0] as Uint8Array;
458
585
  // result[1] is the computed blake3 hash
459
586
  const contentType = "application/octet-stream";
460
587
 
461
- // cache for future use
462
- const arrayBuffer = data.buffer.slice(
463
- data.byteOffset,
464
- data.byteOffset + data.byteLength,
465
- ) as ArrayBuffer;
466
- const response = new Response(arrayBuffer, {
467
- headers: { "Content-Type": contentType },
468
- });
469
- await cache.put(cacheKey(blobId), response);
588
+ await this.storeInCache(cache, blobId, data, contentType, opts);
470
589
 
471
590
  return { data, contentType };
472
591
  } catch (e) {
473
592
  const errorMessage = e instanceof Error ? e.message : String(e);
593
+ attempts.push({ step: "on_demand_blake3", reason: errorMessage });
474
594
  console.warn(
475
595
  `[WasmTransport] on-demand verified download failed, falling back: ${errorMessage}`,
476
596
  );
@@ -485,26 +605,20 @@ export class WasmTransport implements Transport {
485
605
  const data = result.data();
486
606
  const contentType = result.content_type() ?? "application/octet-stream";
487
607
 
488
- // cache for future use
489
- const arrayBuffer = data.buffer.slice(
490
- data.byteOffset,
491
- data.byteOffset + data.byteLength,
492
- ) as ArrayBuffer;
493
- const response = new Response(arrayBuffer, {
494
- headers: { "Content-Type": contentType },
495
- });
496
- await cache.put(cacheKey(blobId), response);
608
+ await this.storeInCache(cache, blobId, data, contentType, opts);
497
609
 
498
610
  return { data, contentType };
499
611
  } catch (e) {
500
612
  const errorMessage = e instanceof Error ? e.message : String(e);
613
+ attempts.push({ step: "legacy_fetch_blob", reason: errorMessage });
501
614
  console.warn(`[WasmTransport] P2P fetch_blob failed: ${errorMessage}`);
502
- throw new Error(`P2P fetch_blob failed: ${errorMessage}`);
615
+ throw new TransportError(`P2P fetch_blob failed: ${errorMessage}`, { attempts });
503
616
  }
504
617
  }
505
618
 
506
- throw new Error(
619
+ throw new TransportError(
507
620
  "no download method available for this blob (no blake3 hash and no legacy fetch_blob)",
621
+ { attempts },
508
622
  );
509
623
  }
510
624
 
@@ -529,6 +643,7 @@ export class WasmTransport implements Transport {
529
643
  blake3?: string,
530
644
  totalBytes?: number,
531
645
  mimeType?: string,
646
+ opts?: BlobFetchOptions,
532
647
  ): Promise<BlobData> {
533
648
  // check Cache API first
534
649
  const cache = await caches.open(this.cacheName);
@@ -536,21 +651,24 @@ export class WasmTransport implements Transport {
536
651
 
537
652
  if (cached) {
538
653
  const data = new Uint8Array(await cached.arrayBuffer());
539
- const contentType =
540
- cached.headers.get("Content-Type") || "application/octet-stream";
654
+ const contentType = cached.headers.get("Content-Type") || "application/octet-stream";
541
655
  // report 100% progress for cached blobs
542
656
  onProgress(data.length, data.length);
543
657
  return { data, contentType };
544
658
  }
545
659
 
660
+ // per-step failure breakdown, attached to the final thrown error so a
661
+ // bug report/log has the full fallback-chain picture, not just the
662
+ // last step's message (see docs/error-handling-tasks.md track P0-E).
663
+ const attempts: Array<{ step: string; reason: string }> = [];
664
+
546
665
  // if blake3 is provided, try iroh-blobs verified download
547
666
  // prefer streaming path (chunk-by-chunk into a Blob) for large files —
548
667
  // it avoids materializing the whole blob in wasm linear memory which
549
668
  // fails around 32MB+ with "encode error".
550
669
  if (blake3) {
551
670
  const streamingFn =
552
- this.node.download_verified_streaming_with_ensure ??
553
- this.node.download_verified_streaming;
671
+ this.node.download_verified_streaming_with_ensure ?? this.node.download_verified_streaming;
554
672
 
555
673
  if (streamingFn) {
556
674
  try {
@@ -579,10 +697,7 @@ export class WasmTransport implements Transport {
579
697
  // truth for the UI when totalBytes is known so we get a smooth
580
698
  // 0..100% even before chunks start flowing in the read phase.
581
699
  if (totalBytes && totalBytes > 0) {
582
- const received = Math.min(
583
- totalBytes,
584
- Math.floor(fraction * totalBytes),
585
- );
700
+ const received = Math.min(totalBytes, Math.floor(fraction * totalBytes));
586
701
  onProgress(received, totalBytes);
587
702
  }
588
703
  },
@@ -600,19 +715,12 @@ export class WasmTransport implements Transport {
600
715
  }
601
716
  chunks.length = 0; // free per-chunk refs early
602
717
 
603
- // cache for future use
604
- const cacheArrayBuffer = data.buffer.slice(
605
- data.byteOffset,
606
- data.byteOffset + data.byteLength,
607
- ) as ArrayBuffer;
608
- const response = new Response(cacheArrayBuffer, {
609
- headers: { "Content-Type": contentType },
610
- });
611
- await cache.put(cacheKey(blobId), response);
718
+ await this.storeInCache(cache, blobId, data, contentType, opts);
612
719
 
613
720
  return { data, contentType };
614
721
  } catch (e) {
615
722
  const errorMessage = e instanceof Error ? e.message : String(e);
723
+ attempts.push({ step: "streaming_verified_download", reason: errorMessage });
616
724
  console.warn(
617
725
  `[WasmTransport] streaming verified download failed, trying non-streaming: ${errorMessage}`,
618
726
  );
@@ -620,8 +728,7 @@ export class WasmTransport implements Transport {
620
728
  }
621
729
  }
622
730
 
623
- const downloadFn =
624
- this.node.download_verified_with_ensure ?? this.node.download_verified;
731
+ const downloadFn = this.node.download_verified_with_ensure ?? this.node.download_verified;
625
732
 
626
733
  if (downloadFn) {
627
734
  try {
@@ -631,22 +738,13 @@ export class WasmTransport implements Transport {
631
738
  // report 100% progress
632
739
  onProgress(data.length, data.length);
633
740
 
634
- // cache for future use
635
- const arrayBuffer = data.buffer.slice(
636
- data.byteOffset,
637
- data.byteOffset + data.byteLength,
638
- ) as ArrayBuffer;
639
- const response = new Response(arrayBuffer, {
640
- headers: { "Content-Type": contentType },
641
- });
642
- await cache.put(cacheKey(blobId), response);
741
+ await this.storeInCache(cache, blobId, data, contentType, opts);
643
742
 
644
743
  return { data, contentType };
645
744
  } catch (e) {
646
745
  const errorMessage = e instanceof Error ? e.message : String(e);
647
- console.warn(
648
- `[WasmTransport] verified download failed, falling back: ${errorMessage}`,
649
- );
746
+ attempts.push({ step: "verified_download", reason: errorMessage });
747
+ console.warn(`[WasmTransport] verified download failed, falling back: ${errorMessage}`);
650
748
  // fall through to regular fetch_blob
651
749
  }
652
750
  }
@@ -657,33 +755,20 @@ export class WasmTransport implements Transport {
657
755
  // fallback because `/api/blobs/{id}/data` only works for DB-backed blobs
658
756
  // and file-backed media blobs would otherwise fail first, then fall back
659
757
  // anyway.
660
- if (
661
- !blake3 &&
662
- this.node.download_verified_by_id &&
663
- mimeType?.startsWith("audio/")
664
- ) {
758
+ if (!blake3 && this.node.download_verified_by_id && mimeType?.startsWith("audio/")) {
665
759
  try {
666
- const result = await this.node.download_verified_by_id(
667
- this.peerAddr,
668
- blobId,
669
- );
760
+ const result = await this.node.download_verified_by_id(this.peerAddr, blobId);
670
761
  const data = result[0] as Uint8Array;
671
762
  const contentType = mimeType || "application/octet-stream";
672
763
 
673
764
  onProgress(data.length, data.length);
674
765
 
675
- const arrayBuffer = data.buffer.slice(
676
- data.byteOffset,
677
- data.byteOffset + data.byteLength,
678
- ) as ArrayBuffer;
679
- const response = new Response(arrayBuffer, {
680
- headers: { "Content-Type": contentType },
681
- });
682
- await cache.put(cacheKey(blobId), response);
766
+ await this.storeInCache(cache, blobId, data, contentType, opts);
683
767
 
684
768
  return { data, contentType };
685
769
  } catch (e) {
686
770
  const errorMessage = e instanceof Error ? e.message : String(e);
771
+ attempts.push({ step: "audio_verified_by_id", reason: errorMessage });
687
772
  console.warn(
688
773
  `[WasmTransport] audio verified-by-id failed, falling back to api_request: ${errorMessage}`,
689
774
  );
@@ -708,30 +793,23 @@ export class WasmTransport implements Transport {
708
793
  // report 100% progress
709
794
  onProgress(data.length, data.length);
710
795
 
711
- // cache for future use with correct content type
712
- const arrayBuffer = data.buffer.slice(
713
- data.byteOffset,
714
- data.byteOffset + data.byteLength,
715
- ) as ArrayBuffer;
716
- const response = new Response(arrayBuffer, {
717
- headers: { "Content-Type": contentType },
718
- });
719
- await cache.put(cacheKey(blobId), response);
796
+ await this.storeInCache(cache, blobId, data, contentType, opts);
720
797
 
721
798
  return { data, contentType };
722
799
  }
723
800
  apiFailureReason = `success=false in api response body for blob ${blobId}`;
801
+ attempts.push({ step: "api_request", reason: apiFailureReason });
724
802
  console.warn(`[WasmTransport] ${apiFailureReason}`);
725
803
  } else {
726
804
  apiFailureReason = `api request returned status ${result.status} for blob ${blobId}`;
805
+ attempts.push({ step: "api_request", reason: apiFailureReason });
727
806
  console.warn(`[WasmTransport] ${apiFailureReason}`);
728
807
  }
729
808
  } catch (e) {
730
809
  const errorMessage = e instanceof Error ? e.message : String(e);
731
810
  apiFailureReason = `api request threw: ${errorMessage}`;
732
- console.warn(
733
- `[WasmTransport] api blob data request failed, falling back: ${errorMessage}`,
734
- );
811
+ attempts.push({ step: "api_request", reason: apiFailureReason });
812
+ console.warn(`[WasmTransport] api blob data request failed, falling back: ${errorMessage}`);
735
813
  }
736
814
 
737
815
  // fallback: try on-demand blake3 computation + verified download via iroh-blobs.
@@ -740,30 +818,19 @@ export class WasmTransport implements Transport {
740
818
  // blake3 for a song.
741
819
  if (!blake3 && this.node.download_verified_by_id) {
742
820
  try {
743
- const result = await this.node.download_verified_by_id(
744
- this.peerAddr,
745
- blobId,
746
- );
821
+ const result = await this.node.download_verified_by_id(this.peerAddr, blobId);
747
822
  const data = result[0] as Uint8Array;
748
823
  const contentType = mimeType || "application/octet-stream";
749
824
 
750
825
  onProgress(data.length, data.length);
751
826
 
752
- const arrayBuffer = data.buffer.slice(
753
- data.byteOffset,
754
- data.byteOffset + data.byteLength,
755
- ) as ArrayBuffer;
756
- const response = new Response(arrayBuffer, {
757
- headers: { "Content-Type": contentType },
758
- });
759
- await cache.put(cacheKey(blobId), response);
827
+ await this.storeInCache(cache, blobId, data, contentType, opts);
760
828
 
761
829
  return { data, contentType };
762
830
  } catch (e) {
763
831
  const errorMessage = e instanceof Error ? e.message : String(e);
764
- console.warn(
765
- `[WasmTransport] on-demand verified download failed: ${errorMessage}`,
766
- );
832
+ attempts.push({ step: "on_demand_blake3", reason: errorMessage });
833
+ console.warn(`[WasmTransport] on-demand verified download failed: ${errorMessage}`);
767
834
  }
768
835
  }
769
836
 
@@ -772,11 +839,7 @@ export class WasmTransport implements Transport {
772
839
  try {
773
840
  let result: BlobResultLike;
774
841
  if (this.node.fetch_blob_with_progress) {
775
- result = await this.node.fetch_blob_with_progress(
776
- this.peerAddr,
777
- blobId,
778
- onProgress,
779
- );
842
+ result = await this.node.fetch_blob_with_progress(this.peerAddr, blobId, onProgress);
780
843
  } else {
781
844
  // fallback to non-progress fetch
782
845
  result = await this.node.fetch_blob!(this.peerAddr, blobId);
@@ -784,33 +847,27 @@ export class WasmTransport implements Transport {
784
847
  const data = result.data();
785
848
  const contentType = result.content_type() ?? "application/octet-stream";
786
849
 
787
- // cache for future use
788
- const arrayBuffer = data.buffer.slice(
789
- data.byteOffset,
790
- data.byteOffset + data.byteLength,
791
- ) as ArrayBuffer;
792
- const response = new Response(arrayBuffer, {
793
- headers: { "Content-Type": contentType },
794
- });
795
- await cache.put(cacheKey(blobId), response);
850
+ await this.storeInCache(cache, blobId, data, contentType, opts);
796
851
 
797
852
  return { data, contentType };
798
853
  } catch (e) {
799
854
  const errorMessage = e instanceof Error ? e.message : String(e);
855
+ attempts.push({ step: "legacy_fetch_blob", reason: errorMessage });
800
856
  console.warn(`[WasmTransport] P2P fetch_blob failed: ${errorMessage}`);
801
- throw new Error(`P2P fetch_blob failed: ${errorMessage}`);
857
+ throw new TransportError(`P2P fetch_blob failed: ${errorMessage}`, { attempts });
802
858
  }
803
859
  }
804
860
 
805
- throw new Error(
861
+ throw new TransportError(
806
862
  `no download method available for blob ${blobId} ` +
807
863
  `(blake3=${blake3 ? "yes" : "no"}, ` +
808
864
  `api=${apiFailureReason ?? "not attempted"}, ` +
809
865
  `legacy_fetch_blob=${this.node.fetch_blob ? "available" : "missing"})`,
866
+ { attempts },
810
867
  );
811
868
  }
812
869
 
813
- async getBlobUrl(blobId: string, blake3?: string): Promise<string> {
870
+ async getBlobUrl(blobId: string, blake3?: string, opts?: BlobFetchOptions): Promise<string> {
814
871
  // check in-memory cache first
815
872
  const cached = this.blobUrlCache.get(blobId);
816
873
  if (cached) {
@@ -818,7 +875,7 @@ export class WasmTransport implements Transport {
818
875
  }
819
876
 
820
877
  // fetch and create object URL (pass blake3 for verified download)
821
- const { data, contentType } = await this.fetchBlob(blobId, blake3);
878
+ const { data, contentType } = await this.fetchBlob(blobId, blake3, opts);
822
879
  const arrayBuffer = data.buffer.slice(
823
880
  data.byteOffset,
824
881
  data.byteOffset + data.byteLength,
@@ -845,6 +902,7 @@ export class WasmTransport implements Transport {
845
902
  blake3?: string,
846
903
  totalBytes?: number,
847
904
  mimeType?: string,
905
+ opts?: BlobFetchOptions,
848
906
  ): Promise<string> {
849
907
  // check in-memory cache first
850
908
  const cached = this.blobUrlCache.get(blobId);
@@ -861,6 +919,7 @@ export class WasmTransport implements Transport {
861
919
  blake3,
862
920
  totalBytes,
863
921
  mimeType,
922
+ opts,
864
923
  );
865
924
  const arrayBuffer = data.buffer.slice(
866
925
  data.byteOffset,
@@ -932,9 +991,7 @@ export class WasmTransport implements Transport {
932
991
  const stream = await this.node.open_bi(this.peerAddr, EVENTS_ALPN);
933
992
  try {
934
993
  const id = 1;
935
- await stream.write_line(
936
- JSON.stringify({ type: "subscribe", id, filter: filter ?? {} }),
937
- );
994
+ await stream.write_line(JSON.stringify({ type: "subscribe", id, filter: filter ?? {} }));
938
995
  // first server frame is always Snapshot for this id
939
996
  const snapLine = await stream.read_line();
940
997
  if (snapLine === null) {
@@ -963,19 +1020,11 @@ export class WasmTransport implements Transport {
963
1020
  }
964
1021
  }
965
1022
 
966
- subscribeJobEvents(
967
- filter?: EventFilter,
968
- signal?: AbortSignal,
969
- ): AsyncIterable<JobEvent> {
1023
+ subscribeJobEvents(filter?: EventFilter, signal?: AbortSignal): AsyncIterable<JobEvent> {
970
1024
  if (!this.node.open_bi) {
971
1025
  return wasmFallbackPollingIterable(this, filter, signal);
972
1026
  }
973
- return wasmSubscribeJobEventsIterable(
974
- this.node,
975
- this.peerAddr,
976
- filter,
977
- signal,
978
- );
1027
+ return wasmSubscribeJobEventsIterable(this.node, this.peerAddr, filter, signal);
979
1028
  }
980
1029
  }
981
1030
 
@@ -1040,9 +1089,7 @@ async function* wasmSubscribeJobEventsIterable(
1040
1089
  signal?.addEventListener("abort", onAbort, { once: true });
1041
1090
 
1042
1091
  try {
1043
- await stream.write_line(
1044
- JSON.stringify({ type: "subscribe", id, filter: filter ?? {} }),
1045
- );
1092
+ await stream.write_line(JSON.stringify({ type: "subscribe", id, filter: filter ?? {} }));
1046
1093
 
1047
1094
  while (true) {
1048
1095
  if (signal?.aborted) return;
@@ -1061,8 +1108,7 @@ async function* wasmSubscribeJobEventsIterable(
1061
1108
  continue;
1062
1109
  }
1063
1110
  if (frame.type === "close") {
1064
- const reasonKind =
1065
- (frame.reason as { kind: string }).kind ?? "internal";
1111
+ const reasonKind = (frame.reason as { kind: string }).kind ?? "internal";
1066
1112
  if (reasonKind === "client_unsubscribed") return;
1067
1113
  throw new JobEventsStreamClosed(frame.reason);
1068
1114
  }