@freqhole/api-client 0.1.30

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.
Files changed (45) hide show
  1. package/auth-test.html +651 -0
  2. package/package.json +42 -0
  3. package/src/AdminClient.ts +154 -0
  4. package/src/CharnelLocalTransport.ts +598 -0
  5. package/src/CharnelTransport.ts +616 -0
  6. package/src/FreqholeClient.ts +261 -0
  7. package/src/WasmTransport.ts +1077 -0
  8. package/src/automerge/IrohNetworkAdapter.ts +727 -0
  9. package/src/automerge/index.ts +14 -0
  10. package/src/codegen/admin_commands.ts +61 -0
  11. package/src/codegen/routes.ts +213 -0
  12. package/src/codegen/schema.ts +6721 -0
  13. package/src/domains/admin.ts +97 -0
  14. package/src/domains/app.ts +98 -0
  15. package/src/domains/auth.ts +191 -0
  16. package/src/domains/favorites.types.ts +52 -0
  17. package/src/domains/jobs.ts +48 -0
  18. package/src/domains/music.ts +1935 -0
  19. package/src/domains/types.ts +19 -0
  20. package/src/domains/upload.ts +214 -0
  21. package/src/index.ts +48 -0
  22. package/src/permissions.ts +468 -0
  23. package/src/playlistz/convert.ts +267 -0
  24. package/src/playlistz/index.ts +6 -0
  25. package/src/playlistz/mutations.ts +181 -0
  26. package/src/playlistz/protocol.ts +222 -0
  27. package/src/playlistz/schema.ts +138 -0
  28. package/src/playlistz/shareLink.ts +76 -0
  29. package/src/storage/blobs.ts +272 -0
  30. package/src/storage/identity.ts +190 -0
  31. package/src/storage/index.ts +5 -0
  32. package/src/storage/webLocks.ts +92 -0
  33. package/src/test/automerge.ts +796 -0
  34. package/src/test/coverage.ts +105 -0
  35. package/src/test/fixtures.ts +460 -0
  36. package/src/test/integration.ts +320 -0
  37. package/src/test/playlistz.ts +631 -0
  38. package/src/test/stateful.ts +561 -0
  39. package/src/test/storage.ts +443 -0
  40. package/src/test.ts +75 -0
  41. package/src/transport.ts +391 -0
  42. package/src/utils.ts +79 -0
  43. package/src/webauthn.ts +228 -0
  44. package/tsconfig.json +22 -0
  45. package/vite.config.js +9 -0
@@ -0,0 +1,1077 @@
1
+ // WASM transport for P2P connections via midden
2
+ //
3
+ // uses midden's MiddenNode to make API requests to peer nodes.
4
+ // blobs are cached in Cache API for audio playback.
5
+
6
+ import type { BlobData, Transport, TransportResponse } from "./transport.js";
7
+ import { snapshotJobEventsViaRequest } from "./transport.js";
8
+ import type {
9
+ CloseReason,
10
+ EventFilter,
11
+ JobEvent,
12
+ JobStateSnapshot,
13
+ } from "./codegen/schema.js";
14
+ import { JobEventsStreamClosed } from "./CharnelLocalTransport.js";
15
+
16
+ /**
17
+ * interface matching midden's BlobResult WASM class
18
+ */
19
+ export interface BlobResultLike {
20
+ data(): Uint8Array;
21
+ size(): number;
22
+ content_type(): string | undefined;
23
+ }
24
+
25
+ /**
26
+ * interface matching midden's BiStream WASM class. used for raw
27
+ * bi-directional streams over arbitrary ALPNs (e.g. freqhole-events/1).
28
+ */
29
+ export interface BiStreamLike {
30
+ peer_node_id(): string;
31
+ alpn(): string;
32
+ write_line(line: string): Promise<void>;
33
+ read_line(): Promise<string | null>;
34
+ // length-prefixed framing (unused here, but matches the wasm class)
35
+ write_message?(data: Uint8Array): Promise<void>;
36
+ read_message?(): Promise<Uint8Array | null>;
37
+ // raw byte primitives (read_to_end / write_raw_and_finish exist too)
38
+ close(): void;
39
+ }
40
+
41
+ /**
42
+ * interface matching midden's MiddenNode WASM class
43
+ * use this type when you don't want to import midden directly
44
+ */
45
+ export interface MiddenNodeLike {
46
+ node_id(): string;
47
+ secret_key(): Uint8Array;
48
+ proxy_request(
49
+ peer_addr: string,
50
+ method: string,
51
+ path: string,
52
+ body?: string | null,
53
+ ): Promise<{ status: number; body: string }>;
54
+ // legacy unverified blob fetch - removed from midden, kept for compat
55
+ fetch_blob?(peer_addr: string, blob_id: string): Promise<BlobResultLike>;
56
+ // optional
57
+ fetch_blob_with_progress?(
58
+ peer_addr: string,
59
+ blob_id: string,
60
+ on_progress: (received: number, total: number) => void,
61
+ ): Promise<BlobResultLike>;
62
+ // fetch server image (public, no auth required) - optional
63
+ // returns HelloImageResult (properties: data, content_type) not BlobResultLike (methods)
64
+ fetch_hello_image?(
65
+ peer_addr: string,
66
+ ): Promise<{ data: Uint8Array; content_type: string | undefined }>;
67
+ // import bytes into local iroh-blobs store, returns blake3 hash (64 hex chars)
68
+ // keeps a TempTag so GC won't collect it until release_blob is called
69
+ import_blob?(data: Uint8Array): Promise<string>;
70
+ // release a blob's TempTag, allowing GC
71
+ release_blob?(blake3_hash: string): void;
72
+ // start background accept loop for incoming iroh-blobs connections
73
+ // must be called once for remote peers to pull blobs from this node
74
+ start_blob_server?(): void;
75
+ // download blob with iroh-blobs verified streaming - optional
76
+ download_verified?(
77
+ peer_addr: string,
78
+ blake3_hash: string,
79
+ ): Promise<Uint8Array>;
80
+ // download blob with automatic ensure + retry - optional
81
+ download_verified_with_ensure?(
82
+ peer_addr: string,
83
+ blake3_hash: string,
84
+ ): Promise<Uint8Array>;
85
+ // download blob by ID with on-demand blake3 computation - optional
86
+ // returns [Uint8Array, string] but typed as any[] for wasm-bindgen compatibility
87
+ download_verified_by_id?(peer_addr: string, blob_id: string): Promise<any[]>;
88
+ // download blob and stream chunks via callback - preferred for large files
89
+ // on_chunk receives (chunk: Uint8Array, offset: number)
90
+ // on_progress receives (fraction: number) in [0, 1]
91
+ // returns total bytes streamed
92
+ download_verified_streaming?(
93
+ peer_addr: string,
94
+ blake3_hash: string,
95
+ total_size: number,
96
+ on_chunk: (chunk: Uint8Array, offset: number) => void,
97
+ on_progress: (fraction: number) => void,
98
+ ): Promise<number>;
99
+ download_verified_streaming_with_ensure?(
100
+ peer_addr: string,
101
+ blake3_hash: string,
102
+ total_size: number,
103
+ on_chunk: (chunk: Uint8Array, offset: number) => void,
104
+ on_progress: (fraction: number) => void,
105
+ ): Promise<number>;
106
+ // dispatch a freqhole-admin/1 ALPN command to a peer.
107
+ // returns the grimoire response envelope `{ success, message, data, errors }`.
108
+ // `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>;
114
+ // tune into a freqhole radio broadcaster (freqhole-radio/1 ALPN).
115
+ // callbacks:
116
+ // on_hello(json: string) — HelloMessage as JSON, fires once on connect
117
+ // on_meta(json: string) — MetaMessage as JSON, fires on each track change
118
+ // on_chunk(seq, isInit, bytes) — fMP4 audio chunk
119
+ // returns a handle whose `.leave()` closes the connection and stops audio.
120
+ tune_radio?(
121
+ peer_addr: string,
122
+ station_id: string | undefined,
123
+ on_hello: (json: string) => void,
124
+ on_meta: (json: string) => void,
125
+ on_chunk: (seq: number, is_init: boolean, bytes: Uint8Array) => void,
126
+ ): Promise<RadioHandleLike>;
127
+ // open a raw bi-directional stream on an arbitrary ALPN. used for
128
+ // job events (freqhole-events/1) and other custom protocols.
129
+ open_bi?(peer_addr: string, alpn: string): Promise<BiStreamLike>;
130
+ }
131
+
132
+ /**
133
+ * handle returned from tune_radio. dropping or calling leave() closes
134
+ * the iroh connection and stops the audio + meta callbacks.
135
+ */
136
+ export interface RadioHandleLike {
137
+ leave(): void;
138
+ }
139
+
140
+ /**
141
+ * progress callback for blob fetching
142
+ * @param received - bytes received so far
143
+ * @param total - total bytes expected (0 if unknown)
144
+ */
145
+ export type BlobProgressCallback = (received: number, total: number) => void;
146
+
147
+ // unified cache for all remote blobs (HTTP + P2P) - default if no custom cache name provided
148
+ const DEFAULT_CACHE_NAME = "freqhole-blobs-v1";
149
+
150
+ /**
151
+ * decode base64 string to Uint8Array
152
+ * handles both standard and URL-safe base64 encoding
153
+ */
154
+ function base64ToBytes(base64: string): Uint8Array {
155
+ // convert URL-safe base64 to standard base64
156
+ let standardBase64 = base64.replace(/-/g, "+").replace(/_/g, "/");
157
+ // add padding if needed
158
+ const padLen = (4 - (standardBase64.length % 4)) % 4;
159
+ standardBase64 += "=".repeat(padLen);
160
+
161
+ const binaryString = atob(standardBase64);
162
+ const bytes = new Uint8Array(binaryString.length);
163
+ for (let i = 0; i < binaryString.length; i++) {
164
+ bytes[i] = binaryString.charCodeAt(i);
165
+ }
166
+ return bytes;
167
+ }
168
+
169
+ // webkitgtk (linux) requires HTTP/HTTPS URLs for Cache API keys.
170
+ // wrap bare blobIds with a synthetic URL prefix.
171
+ // must match CharnelTransport's cacheKey format for consistency.
172
+ function cacheKey(blobId: string): string {
173
+ return `https://blob.local/${blobId}`;
174
+ }
175
+
176
+ /**
177
+ * WASM transport - uses midden for P2P connections
178
+ *
179
+ * usage:
180
+ * ```typescript
181
+ * import { MiddenNode } from "midden";
182
+ * import { WasmTransport, createClient } from "freqhole-api-client";
183
+ *
184
+ * const node = await MiddenNode.create();
185
+ * const transport = new WasmTransport(node, peerNodeId);
186
+ * const client = createClient(transport);
187
+ * ```
188
+ */
189
+ export class WasmTransport implements Transport {
190
+ private blobUrlCache = new Map<string, string>();
191
+ private readonly cacheName: string;
192
+
193
+ constructor(
194
+ private readonly node: MiddenNodeLike,
195
+ private readonly peerAddr: string, // node_id or full endpoint JSON
196
+ cacheName?: string, // optional custom cache name for per-remote caching
197
+ ) {
198
+ this.cacheName = cacheName ?? DEFAULT_CACHE_NAME;
199
+ }
200
+
201
+ /**
202
+ * get our local node's ID
203
+ */
204
+ get localNodeId(): string {
205
+ return this.node.node_id();
206
+ }
207
+
208
+ /**
209
+ * get the peer address this transport connects to
210
+ */
211
+ get remotePeerAddr(): string {
212
+ return this.peerAddr;
213
+ }
214
+
215
+ async request(
216
+ method: string,
217
+ path: string,
218
+ body?: string,
219
+ ): Promise<TransportResponse> {
220
+ try {
221
+ const result = await this.node.proxy_request(
222
+ this.peerAddr,
223
+ method,
224
+ path,
225
+ body ?? null,
226
+ );
227
+ return {
228
+ status: result.status,
229
+ body: result.body,
230
+ };
231
+ } catch (e) {
232
+ // 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}`);
236
+ }
237
+ }
238
+
239
+ async upload(path: string, formData: FormData): Promise<TransportResponse> {
240
+ const file = formData.get("file") as File | null;
241
+ if (!file) {
242
+ return {
243
+ status: 400,
244
+ body: JSON.stringify({
245
+ success: false,
246
+ message: "no file provided",
247
+ errors: [
248
+ {
249
+ error_type: "bad_request",
250
+ title: "bad request",
251
+ detail: "no file provided",
252
+ },
253
+ ],
254
+ }),
255
+ };
256
+ }
257
+
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);
261
+ }
262
+
263
+ // fallback: base64 encode and send via proxy_request (works for image uploads)
264
+ return this.uploadViaBase64(path, file, formData);
265
+ }
266
+
267
+ /**
268
+ * upload via iroh-blobs pull model:
269
+ * 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
271
+ * 3. remote peer pulls the blob from us via iroh-blobs verified streaming
272
+ * 4. release the TempTag so local GC can reclaim the blob
273
+ */
274
+ private async uploadViaIrohBlobs(
275
+ file: File,
276
+ formData: FormData,
277
+ ): Promise<TransportResponse> {
278
+ try {
279
+ const fileBytes = new Uint8Array(await file.arrayBuffer());
280
+ const hash = await this.node.import_blob!(fileBytes);
281
+
282
+ try {
283
+ const body: Record<string, unknown> = {
284
+ blake3: hash,
285
+ filename: file.name,
286
+ size: fileBytes.length,
287
+ };
288
+
289
+ // include metadata if present (parsed as JSON)
290
+ const metadataStr = formData.get("metadata") as string | null;
291
+ if (metadataStr) {
292
+ try {
293
+ body.metadata = JSON.parse(metadataStr);
294
+ } catch {
295
+ // ignore parse errors
296
+ }
297
+ }
298
+
299
+ // include associate_with if present
300
+ const associateWithStr = formData.get("associate_with") as
301
+ | string
302
+ | null;
303
+ if (associateWithStr) {
304
+ try {
305
+ body.associate_with = JSON.parse(associateWithStr);
306
+ } catch {
307
+ // ignore parse errors
308
+ }
309
+ }
310
+
311
+ const response = await this.request(
312
+ "POST",
313
+ "/api/upload/music-by-blake3",
314
+ JSON.stringify(body),
315
+ );
316
+ return response;
317
+ } finally {
318
+ // release TempTag so local GC can reclaim the blob
319
+ this.node.release_blob?.(hash);
320
+ }
321
+ } catch (error) {
322
+ console.error("[WasmTransport] upload failed:", error);
323
+ throw error;
324
+ }
325
+ }
326
+
327
+ /**
328
+ * fallback upload via base64 encoding
329
+ * used for image uploads (which work via offal dispatch) and when
330
+ * iroh-blobs import_blob is not available
331
+ */
332
+ private async uploadViaBase64(
333
+ path: string,
334
+ file: File,
335
+ formData: FormData,
336
+ ): Promise<TransportResponse> {
337
+ if (path === "/api/upload/music") {
338
+ console.warn(
339
+ "[WasmTransport] falling back to base64 upload for music (import_blob not available)",
340
+ );
341
+ }
342
+
343
+ const arrayBuffer = await file.arrayBuffer();
344
+ const base64 = btoa(
345
+ new Uint8Array(arrayBuffer).reduce(
346
+ (data, byte) => data + String.fromCharCode(byte),
347
+ "",
348
+ ),
349
+ );
350
+
351
+ const body: Record<string, unknown> = {
352
+ data: base64,
353
+ filename: file.name,
354
+ };
355
+
356
+ // include associate_with if present
357
+ const associateWithStr = formData.get("associate_with") as string | null;
358
+ if (associateWithStr) {
359
+ try {
360
+ body.associate_with = JSON.parse(associateWithStr);
361
+ } catch {
362
+ // ignore parse errors
363
+ }
364
+ }
365
+
366
+ // send via proxy_request — routes through offal dispatch on the remote peer
367
+ return this.request("POST", path, JSON.stringify(body));
368
+ }
369
+
370
+ async fetchBlob(blobId: string, blake3?: string): Promise<BlobData> {
371
+ // check Cache API first
372
+ const cache = await caches.open(this.cacheName);
373
+ const cached = await cache.match(cacheKey(blobId));
374
+
375
+ if (cached) {
376
+ const data = new Uint8Array(await cached.arrayBuffer());
377
+ const contentType =
378
+ cached.headers.get("Content-Type") || "application/octet-stream";
379
+ return { data, contentType };
380
+ }
381
+
382
+ // if blake3 is provided, try iroh-blobs verified download
383
+ // prefer download_verified_with_ensure (handles on-demand loading)
384
+ // fall back to download_verified if that's not available
385
+ if (blake3) {
386
+ const downloadFn =
387
+ this.node.download_verified_with_ensure ?? this.node.download_verified;
388
+
389
+ if (downloadFn) {
390
+ try {
391
+ const data = await downloadFn.call(this.node, this.peerAddr, blake3);
392
+ const contentType = "audio/mpeg"; // iroh-blobs doesn't track content type, assume audio
393
+
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);
403
+
404
+ return { data, contentType };
405
+ } catch (e) {
406
+ const errorMessage = e instanceof Error ? e.message : String(e);
407
+ console.warn(
408
+ `[WasmTransport] verified download failed, falling back: ${errorMessage}`,
409
+ );
410
+ // fall through to proxy fetch
411
+ }
412
+ }
413
+ }
414
+
415
+ // no blake3 (or verified download failed) — try proxy_request to get blob data
416
+ // this is the primary path for images (waveforms, thumbnails) stored in the database
417
+ try {
418
+ const result = await this.node.proxy_request(
419
+ this.peerAddr,
420
+ "GET",
421
+ `/api/blobs/${blobId}/data`,
422
+ null,
423
+ );
424
+ if (result.status === 200) {
425
+ const parsed = JSON.parse(result.body);
426
+ if (parsed.success && parsed.data?.data) {
427
+ const data = base64ToBytes(parsed.data.data);
428
+ const contentType = parsed.data.mime || "application/octet-stream";
429
+
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);
439
+
440
+ return { data, contentType };
441
+ }
442
+ }
443
+ } catch (e) {
444
+ const errorMessage = e instanceof Error ? e.message : String(e);
445
+ console.warn(
446
+ `[WasmTransport] proxy blob data request failed, falling back: ${errorMessage}`,
447
+ );
448
+ }
449
+
450
+ // fallback: try on-demand blake3 computation + verified download via iroh-blobs
451
+ if (!blake3 && this.node.download_verified_by_id) {
452
+ try {
453
+ const result = await this.node.download_verified_by_id(
454
+ this.peerAddr,
455
+ blobId,
456
+ );
457
+ const data = result[0] as Uint8Array;
458
+ // result[1] is the computed blake3 hash
459
+ const contentType = "application/octet-stream";
460
+
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);
470
+
471
+ return { data, contentType };
472
+ } catch (e) {
473
+ const errorMessage = e instanceof Error ? e.message : String(e);
474
+ console.warn(
475
+ `[WasmTransport] on-demand verified download failed, falling back: ${errorMessage}`,
476
+ );
477
+ // fall through to legacy fetch_blob
478
+ }
479
+ }
480
+
481
+ // fetch from peer using legacy protocol (if available)
482
+ if (this.node.fetch_blob) {
483
+ try {
484
+ const result = await this.node.fetch_blob(this.peerAddr, blobId);
485
+ const data = result.data();
486
+ const contentType = result.content_type() ?? "application/octet-stream";
487
+
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);
497
+
498
+ return { data, contentType };
499
+ } catch (e) {
500
+ const errorMessage = e instanceof Error ? e.message : String(e);
501
+ console.warn(`[WasmTransport] P2P fetch_blob failed: ${errorMessage}`);
502
+ throw new Error(`P2P fetch_blob failed: ${errorMessage}`);
503
+ }
504
+ }
505
+
506
+ throw new Error(
507
+ "no download method available for this blob (no blake3 hash and no legacy fetch_blob)",
508
+ );
509
+ }
510
+
511
+ /**
512
+ * fetch a blob with progress callback
513
+ * @param blobId - the blob ID to fetch
514
+ * @param onProgress - callback with (received, total) bytes
515
+ * @param blake3 - optional blake3 hash for verified streaming via iroh-blobs
516
+ * @param totalBytes - optional known total size; if provided, used as the
517
+ * `total` argument to onProgress so the UI can render an accurate 0-100%
518
+ * progress bar (the underlying iroh-blobs stream does not always report
519
+ * total size before bytes start flowing)
520
+ * @param mimeType - optional content type. midden's streaming path doesn't
521
+ * surface the source mime, so callers should pass it when known
522
+ * (e.g. song.mime_type). defaults to audio/mpeg in the streaming branch
523
+ * and application/octet-stream in fallback branches.
524
+ * @returns blob data with content type
525
+ */
526
+ async fetchBlobWithProgress(
527
+ blobId: string,
528
+ onProgress: BlobProgressCallback,
529
+ blake3?: string,
530
+ totalBytes?: number,
531
+ mimeType?: string,
532
+ ): Promise<BlobData> {
533
+ // check Cache API first
534
+ const cache = await caches.open(this.cacheName);
535
+ const cached = await cache.match(cacheKey(blobId));
536
+
537
+ if (cached) {
538
+ const data = new Uint8Array(await cached.arrayBuffer());
539
+ const contentType =
540
+ cached.headers.get("Content-Type") || "application/octet-stream";
541
+ // report 100% progress for cached blobs
542
+ onProgress(data.length, data.length);
543
+ return { data, contentType };
544
+ }
545
+
546
+ // if blake3 is provided, try iroh-blobs verified download
547
+ // prefer streaming path (chunk-by-chunk into a Blob) for large files —
548
+ // it avoids materializing the whole blob in wasm linear memory which
549
+ // fails around 32MB+ with "encode error".
550
+ if (blake3) {
551
+ const streamingFn =
552
+ this.node.download_verified_streaming_with_ensure ??
553
+ this.node.download_verified_streaming;
554
+
555
+ if (streamingFn) {
556
+ try {
557
+ const chunks: Uint8Array[] = [];
558
+ let totalReceived = 0;
559
+ await streamingFn.call(
560
+ this.node,
561
+ this.peerAddr,
562
+ blake3,
563
+ totalBytes ?? 0,
564
+ (chunk: Uint8Array, _offset: number) => {
565
+ // chunk is a wasm-owned Uint8Array view — copy to detach
566
+ const owned = new Uint8Array(chunk.length);
567
+ owned.set(chunk);
568
+ chunks.push(owned);
569
+ totalReceived += owned.length;
570
+ // when totalBytes is unknown, drive UI from chunk arrivals
571
+ // (consumer treats received==total as indeterminate)
572
+ if (!totalBytes || totalBytes <= 0) {
573
+ onProgress(totalReceived, totalReceived);
574
+ }
575
+ },
576
+ (fraction: number) => {
577
+ // unified progress: midden reports fraction across both phases
578
+ // (download = 0..0.5, read = 0.5..1.0). use as the source of
579
+ // truth for the UI when totalBytes is known so we get a smooth
580
+ // 0..100% even before chunks start flowing in the read phase.
581
+ if (totalBytes && totalBytes > 0) {
582
+ const received = Math.min(
583
+ totalBytes,
584
+ Math.floor(fraction * totalBytes),
585
+ );
586
+ onProgress(received, totalBytes);
587
+ }
588
+ },
589
+ );
590
+
591
+ // concat chunks into a single Uint8Array. one allocation
592
+ // sized exactly to totalReceived (vs the previous Blob → arrayBuffer
593
+ // → Uint8Array round-trip which transiently held ~3x the bytes).
594
+ const contentType = mimeType ?? "audio/mpeg";
595
+ const data = new Uint8Array(totalReceived);
596
+ let offset = 0;
597
+ for (const c of chunks) {
598
+ data.set(c, offset);
599
+ offset += c.length;
600
+ }
601
+ chunks.length = 0; // free per-chunk refs early
602
+
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);
612
+
613
+ return { data, contentType };
614
+ } catch (e) {
615
+ const errorMessage = e instanceof Error ? e.message : String(e);
616
+ console.warn(
617
+ `[WasmTransport] streaming verified download failed, trying non-streaming: ${errorMessage}`,
618
+ );
619
+ // fall through to non-streaming verified path
620
+ }
621
+ }
622
+
623
+ const downloadFn =
624
+ this.node.download_verified_with_ensure ?? this.node.download_verified;
625
+
626
+ if (downloadFn) {
627
+ try {
628
+ const data = await downloadFn.call(this.node, this.peerAddr, blake3);
629
+ const contentType = "audio/mpeg";
630
+
631
+ // report 100% progress
632
+ onProgress(data.length, data.length);
633
+
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);
643
+
644
+ return { data, contentType };
645
+ } catch (e) {
646
+ const errorMessage = e instanceof Error ? e.message : String(e);
647
+ console.warn(
648
+ `[WasmTransport] verified download failed, falling back: ${errorMessage}`,
649
+ );
650
+ // fall through to regular fetch_blob
651
+ }
652
+ }
653
+ }
654
+
655
+ // no blake3 (or verified download failed).
656
+ // for audio blobs, prefer verified-by-id before proxy `/data` because
657
+ // `/api/blobs/{id}/data` only works for DB-backed blobs and file-backed
658
+ // media blobs would otherwise fail first, then fall back anyway.
659
+ if (
660
+ !blake3 &&
661
+ this.node.download_verified_by_id &&
662
+ mimeType?.startsWith("audio/")
663
+ ) {
664
+ try {
665
+ const result = await this.node.download_verified_by_id(
666
+ this.peerAddr,
667
+ blobId,
668
+ );
669
+ const data = result[0] as Uint8Array;
670
+ const contentType = mimeType || "application/octet-stream";
671
+
672
+ onProgress(data.length, data.length);
673
+
674
+ const arrayBuffer = data.buffer.slice(
675
+ data.byteOffset,
676
+ data.byteOffset + data.byteLength,
677
+ ) as ArrayBuffer;
678
+ const response = new Response(arrayBuffer, {
679
+ headers: { "Content-Type": contentType },
680
+ });
681
+ await cache.put(cacheKey(blobId), response);
682
+
683
+ return { data, contentType };
684
+ } catch (e) {
685
+ const errorMessage = e instanceof Error ? e.message : String(e);
686
+ console.warn(
687
+ `[WasmTransport] audio verified-by-id failed, falling back to proxy: ${errorMessage}`,
688
+ );
689
+ }
690
+ }
691
+
692
+ // try proxy_request to get blob data
693
+ let proxyFailureReason: string | null = null;
694
+ try {
695
+ const result = await this.node.proxy_request(
696
+ this.peerAddr,
697
+ "GET",
698
+ `/api/blobs/${blobId}/data`,
699
+ null,
700
+ );
701
+ if (result.status === 200) {
702
+ const parsed = JSON.parse(result.body);
703
+ if (parsed.success && parsed.data?.data) {
704
+ const data = base64ToBytes(parsed.data.data);
705
+ const contentType = parsed.data.mime || "application/octet-stream";
706
+
707
+ // report 100% progress
708
+ onProgress(data.length, data.length);
709
+
710
+ // cache for future use with correct content type
711
+ const arrayBuffer = data.buffer.slice(
712
+ data.byteOffset,
713
+ data.byteOffset + data.byteLength,
714
+ ) as ArrayBuffer;
715
+ const response = new Response(arrayBuffer, {
716
+ headers: { "Content-Type": contentType },
717
+ });
718
+ await cache.put(cacheKey(blobId), response);
719
+
720
+ return { data, contentType };
721
+ }
722
+ proxyFailureReason = `success=false in proxy response body for blob ${blobId}`;
723
+ console.warn(`[WasmTransport] ${proxyFailureReason}`);
724
+ } else {
725
+ proxyFailureReason = `proxy returned status ${result.status} for blob ${blobId}`;
726
+ console.warn(`[WasmTransport] ${proxyFailureReason}`);
727
+ }
728
+ } catch (e) {
729
+ const errorMessage = e instanceof Error ? e.message : String(e);
730
+ proxyFailureReason = `proxy request threw: ${errorMessage}`;
731
+ console.warn(
732
+ `[WasmTransport] proxy blob data request failed, falling back: ${errorMessage}`,
733
+ );
734
+ }
735
+
736
+ // fallback: try on-demand blake3 computation + verified download via iroh-blobs.
737
+ // this matches fetchBlob() so callers using the progress path (radio
738
+ // timeline playback) do not fail just because the API response omitted
739
+ // blake3 for a song.
740
+ if (!blake3 && this.node.download_verified_by_id) {
741
+ try {
742
+ const result = await this.node.download_verified_by_id(
743
+ this.peerAddr,
744
+ blobId,
745
+ );
746
+ const data = result[0] as Uint8Array;
747
+ const contentType = mimeType || "application/octet-stream";
748
+
749
+ onProgress(data.length, data.length);
750
+
751
+ const arrayBuffer = data.buffer.slice(
752
+ data.byteOffset,
753
+ data.byteOffset + data.byteLength,
754
+ ) as ArrayBuffer;
755
+ const response = new Response(arrayBuffer, {
756
+ headers: { "Content-Type": contentType },
757
+ });
758
+ await cache.put(cacheKey(blobId), response);
759
+
760
+ return { data, contentType };
761
+ } catch (e) {
762
+ const errorMessage = e instanceof Error ? e.message : String(e);
763
+ console.warn(
764
+ `[WasmTransport] on-demand verified download failed: ${errorMessage}`,
765
+ );
766
+ }
767
+ }
768
+
769
+ // fetch from peer using legacy protocol (if available)
770
+ if (this.node.fetch_blob_with_progress || this.node.fetch_blob) {
771
+ try {
772
+ let result: BlobResultLike;
773
+ if (this.node.fetch_blob_with_progress) {
774
+ result = await this.node.fetch_blob_with_progress(
775
+ this.peerAddr,
776
+ blobId,
777
+ onProgress,
778
+ );
779
+ } else {
780
+ // fallback to non-progress fetch
781
+ result = await this.node.fetch_blob!(this.peerAddr, blobId);
782
+ }
783
+ const data = result.data();
784
+ const contentType = result.content_type() ?? "application/octet-stream";
785
+
786
+ // cache for future use
787
+ const arrayBuffer = data.buffer.slice(
788
+ data.byteOffset,
789
+ data.byteOffset + data.byteLength,
790
+ ) as ArrayBuffer;
791
+ const response = new Response(arrayBuffer, {
792
+ headers: { "Content-Type": contentType },
793
+ });
794
+ await cache.put(cacheKey(blobId), response);
795
+
796
+ return { data, contentType };
797
+ } catch (e) {
798
+ const errorMessage = e instanceof Error ? e.message : String(e);
799
+ console.warn(`[WasmTransport] P2P fetch_blob failed: ${errorMessage}`);
800
+ throw new Error(`P2P fetch_blob failed: ${errorMessage}`);
801
+ }
802
+ }
803
+
804
+ throw new Error(
805
+ `no download method available for blob ${blobId} ` +
806
+ `(blake3=${blake3 ? "yes" : "no"}, ` +
807
+ `proxy=${proxyFailureReason ?? "not attempted"}, ` +
808
+ `legacy_fetch_blob=${this.node.fetch_blob ? "available" : "missing"})`,
809
+ );
810
+ }
811
+
812
+ async getBlobUrl(blobId: string, blake3?: string): Promise<string> {
813
+ // check in-memory cache first
814
+ const cached = this.blobUrlCache.get(blobId);
815
+ if (cached) {
816
+ return cached;
817
+ }
818
+
819
+ // fetch and create object URL (pass blake3 for verified download)
820
+ const { data, contentType } = await this.fetchBlob(blobId, blake3);
821
+ const arrayBuffer = data.buffer.slice(
822
+ data.byteOffset,
823
+ data.byteOffset + data.byteLength,
824
+ ) as ArrayBuffer;
825
+ const blob = new Blob([arrayBuffer], { type: contentType });
826
+ const url = URL.createObjectURL(blob);
827
+
828
+ this.blobUrlCache.set(blobId, url);
829
+ return url;
830
+ }
831
+
832
+ /**
833
+ * get a blob URL with progress callback
834
+ * @param blobId - the blob ID to fetch
835
+ * @param onProgress - callback with (received, total) bytes
836
+ * @param blake3 - optional blake3 hash for verified streaming via iroh-blobs
837
+ * @param totalBytes - optional known total size in bytes
838
+ * @param mimeType - optional content type for the assembled blob
839
+ * @returns URL usable in <audio>/<img> src
840
+ */
841
+ async getBlobUrlWithProgress(
842
+ blobId: string,
843
+ onProgress: BlobProgressCallback,
844
+ blake3?: string,
845
+ totalBytes?: number,
846
+ mimeType?: string,
847
+ ): Promise<string> {
848
+ // check in-memory cache first
849
+ const cached = this.blobUrlCache.get(blobId);
850
+ if (cached) {
851
+ // report 100% progress for cached URLs
852
+ onProgress(1, 1);
853
+ return cached;
854
+ }
855
+
856
+ // fetch with progress and create object URL (pass blake3 for verified download)
857
+ const { data, contentType } = await this.fetchBlobWithProgress(
858
+ blobId,
859
+ onProgress,
860
+ blake3,
861
+ totalBytes,
862
+ mimeType,
863
+ );
864
+ const arrayBuffer = data.buffer.slice(
865
+ data.byteOffset,
866
+ data.byteOffset + data.byteLength,
867
+ ) as ArrayBuffer;
868
+ const blob = new Blob([arrayBuffer], { type: contentType });
869
+ const url = URL.createObjectURL(blob);
870
+
871
+ this.blobUrlCache.set(blobId, url);
872
+ return url;
873
+ }
874
+
875
+ /**
876
+ * revoke a blob URL to free memory
877
+ */
878
+ revokeBlobUrl(blobId: string): void {
879
+ const url = this.blobUrlCache.get(blobId);
880
+ if (url) {
881
+ URL.revokeObjectURL(url);
882
+ this.blobUrlCache.delete(blobId);
883
+ }
884
+ }
885
+
886
+ /**
887
+ * clear all cached blob URLs
888
+ */
889
+ clearBlobUrls(): void {
890
+ for (const url of this.blobUrlCache.values()) {
891
+ URL.revokeObjectURL(url);
892
+ }
893
+ this.blobUrlCache.clear();
894
+ }
895
+
896
+ /**
897
+ * fetch server image (public, no auth required)
898
+ * used during "add remote" flow before user is authenticated
899
+ * @returns blob data with content type, or null if method not available
900
+ */
901
+ async fetchHelloImage(): Promise<BlobData | null> {
902
+ if (!this.node.fetch_hello_image) {
903
+ console.warn("fetch_hello_image not available - midden rebuild required");
904
+ return null;
905
+ }
906
+
907
+ const result = await this.node.fetch_hello_image(this.peerAddr);
908
+ const data = result.data;
909
+ const contentType = result.content_type ?? "image/png";
910
+
911
+ return { data, contentType };
912
+ }
913
+
914
+ // -----------------------------------------------------------------
915
+ // job events (freqhole-events/1 ALPN over midden bi-stream).
916
+ //
917
+ // snapshot opens a one-shot stream: subscribe, read snapshot, send
918
+ // unsubscribe, close. subscribe opens a long-lived stream that yields
919
+ // each Event frame. ndjson framing matches the rust EventsClientMsg/
920
+ // EventsServerMsg wire format.
921
+ //
922
+ // if midden's `open_bi` is missing (older midden build), both methods
923
+ // fall back to http snapshot / polling so callers don't need to
924
+ // feature-detect.
925
+ // -----------------------------------------------------------------
926
+
927
+ async snapshotJobEvents(filter?: EventFilter): Promise<JobStateSnapshot[]> {
928
+ if (!this.node.open_bi) {
929
+ return snapshotJobEventsViaRequest(this, filter);
930
+ }
931
+ const stream = await this.node.open_bi(this.peerAddr, EVENTS_ALPN);
932
+ try {
933
+ const id = 1;
934
+ await stream.write_line(
935
+ JSON.stringify({ type: "subscribe", id, filter: filter ?? {} }),
936
+ );
937
+ // first server frame is always Snapshot for this id
938
+ const snapLine = await stream.read_line();
939
+ if (snapLine === null) {
940
+ throw new Error("snapshotJobEvents: stream closed before snapshot");
941
+ }
942
+ const frame = JSON.parse(snapLine) as EventsServerWire;
943
+ if (frame.type !== "snapshot") {
944
+ if (frame.type === "close") {
945
+ throw new JobEventsStreamClosed(frame.reason);
946
+ }
947
+ throw new Error(`snapshotJobEvents: unexpected frame type ${frame.type}`);
948
+ }
949
+ // best-effort unsubscribe; ignore errors
950
+ try {
951
+ await stream.write_line(JSON.stringify({ type: "unsubscribe", id }));
952
+ } catch {
953
+ /* noop */
954
+ }
955
+ return frame.items;
956
+ } finally {
957
+ try {
958
+ stream.close();
959
+ } catch {
960
+ /* noop */
961
+ }
962
+ }
963
+ }
964
+
965
+ subscribeJobEvents(
966
+ filter?: EventFilter,
967
+ signal?: AbortSignal,
968
+ ): AsyncIterable<JobEvent> {
969
+ if (!this.node.open_bi) {
970
+ return wasmFallbackPollingIterable(this, filter, signal);
971
+ }
972
+ return wasmSubscribeJobEventsIterable(
973
+ this.node,
974
+ this.peerAddr,
975
+ filter,
976
+ signal,
977
+ );
978
+ }
979
+ }
980
+
981
+ // fallback when midden lacks open_bi (older build): same polling iterator
982
+ // HttpTransport uses, lazily imported to avoid a hard cycle with transport.js.
983
+ async function* wasmFallbackPollingIterable(
984
+ transport: Transport,
985
+ filter: EventFilter | undefined,
986
+ signal: AbortSignal | undefined,
987
+ ): AsyncGenerator<JobEvent, void, void> {
988
+ const { pollingJobEvents } = await import("./transport.js");
989
+ yield* pollingJobEvents(transport, filter, signal);
990
+ }
991
+
992
+ // ---------------------------------------------------------------------
993
+ // wire types for the freqhole-events/1 ndjson protocol
994
+ // (mirrors grimoire/src/federation/transport/events_protocol.rs)
995
+ // ---------------------------------------------------------------------
996
+
997
+ const EVENTS_ALPN = "freqhole-events/1";
998
+
999
+ type EventsServerWire =
1000
+ | { type: "snapshot"; id: number; items: JobStateSnapshot[] }
1001
+ | { type: "event"; id: number; evt: JobEvent }
1002
+ | { type: "close"; id: number; reason: CloseReason };
1003
+
1004
+ /**
1005
+ * async-iterable subscribe over a midden bi-stream. opens once, writes
1006
+ * one `subscribe`, reads `snapshot`+events, surfaces `close` as a
1007
+ * `JobEventsStreamClosed` error (except for `client_unsubscribed`).
1008
+ *
1009
+ * the snapshot itself is *not* yielded as an event — consumers that
1010
+ * want both should call `snapshotJobEvents()` separately. this matches
1011
+ * the contract `pollingJobEvents` / `charnelJobEventsIterable` follow.
1012
+ */
1013
+ async function* wasmSubscribeJobEventsIterable(
1014
+ node: MiddenNodeLike,
1015
+ peerAddr: string,
1016
+ filter: EventFilter | undefined,
1017
+ signal: AbortSignal | undefined,
1018
+ ): AsyncGenerator<JobEvent, void, void> {
1019
+ if (!node.open_bi) {
1020
+ throw new Error("midden node has no open_bi method (rebuild midden)");
1021
+ }
1022
+ const stream = await node.open_bi(peerAddr, EVENTS_ALPN);
1023
+ const id = 1;
1024
+
1025
+ const onAbort = () => {
1026
+ // best-effort: write unsubscribe + close. errors are swallowed
1027
+ // because the stream may already be torn down.
1028
+ stream
1029
+ .write_line(JSON.stringify({ type: "unsubscribe", id }))
1030
+ .catch(() => {})
1031
+ .finally(() => {
1032
+ try {
1033
+ stream.close();
1034
+ } catch {
1035
+ /* noop */
1036
+ }
1037
+ });
1038
+ };
1039
+ signal?.addEventListener("abort", onAbort, { once: true });
1040
+
1041
+ try {
1042
+ await stream.write_line(
1043
+ JSON.stringify({ type: "subscribe", id, filter: filter ?? {} }),
1044
+ );
1045
+
1046
+ while (true) {
1047
+ if (signal?.aborted) return;
1048
+ const line = await stream.read_line();
1049
+ if (line === null) {
1050
+ // stream finished without an explicit close frame
1051
+ throw new JobEventsStreamClosed({ kind: "internal" } as CloseReason);
1052
+ }
1053
+ const frame = JSON.parse(line) as EventsServerWire;
1054
+ if (frame.type === "snapshot") {
1055
+ // server always sends snapshot first; we just skip it here
1056
+ continue;
1057
+ }
1058
+ if (frame.type === "event") {
1059
+ yield frame.evt;
1060
+ continue;
1061
+ }
1062
+ if (frame.type === "close") {
1063
+ const reasonKind =
1064
+ (frame.reason as { kind: string }).kind ?? "internal";
1065
+ if (reasonKind === "client_unsubscribed") return;
1066
+ throw new JobEventsStreamClosed(frame.reason);
1067
+ }
1068
+ }
1069
+ } finally {
1070
+ signal?.removeEventListener("abort", onAbort);
1071
+ try {
1072
+ stream.close();
1073
+ } catch {
1074
+ /* noop */
1075
+ }
1076
+ }
1077
+ }