@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.
- package/auth-test.html +651 -0
- package/package.json +42 -0
- package/src/AdminClient.ts +154 -0
- package/src/CharnelLocalTransport.ts +598 -0
- package/src/CharnelTransport.ts +616 -0
- package/src/FreqholeClient.ts +261 -0
- package/src/WasmTransport.ts +1077 -0
- package/src/automerge/IrohNetworkAdapter.ts +727 -0
- package/src/automerge/index.ts +14 -0
- package/src/codegen/admin_commands.ts +61 -0
- package/src/codegen/routes.ts +213 -0
- package/src/codegen/schema.ts +6721 -0
- package/src/domains/admin.ts +97 -0
- package/src/domains/app.ts +98 -0
- package/src/domains/auth.ts +191 -0
- package/src/domains/favorites.types.ts +52 -0
- package/src/domains/jobs.ts +48 -0
- package/src/domains/music.ts +1935 -0
- package/src/domains/types.ts +19 -0
- package/src/domains/upload.ts +214 -0
- package/src/index.ts +48 -0
- package/src/permissions.ts +468 -0
- package/src/playlistz/convert.ts +267 -0
- package/src/playlistz/index.ts +6 -0
- package/src/playlistz/mutations.ts +181 -0
- package/src/playlistz/protocol.ts +222 -0
- package/src/playlistz/schema.ts +138 -0
- package/src/playlistz/shareLink.ts +76 -0
- package/src/storage/blobs.ts +272 -0
- package/src/storage/identity.ts +190 -0
- package/src/storage/index.ts +5 -0
- package/src/storage/webLocks.ts +92 -0
- package/src/test/automerge.ts +796 -0
- package/src/test/coverage.ts +105 -0
- package/src/test/fixtures.ts +460 -0
- package/src/test/integration.ts +320 -0
- package/src/test/playlistz.ts +631 -0
- package/src/test/stateful.ts +561 -0
- package/src/test/storage.ts +443 -0
- package/src/test.ts +75 -0
- package/src/transport.ts +391 -0
- package/src/utils.ts +79 -0
- package/src/webauthn.ts +228 -0
- package/tsconfig.json +22 -0
- 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
|
+
}
|