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