@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freqhole/api-client",
3
- "version": "0.2.14",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,15 +22,17 @@
22
22
  "auth-test": "vite --config vite.config.js"
23
23
  },
24
24
  "dependencies": {
25
- "zod": "^4.3.5",
26
- "@freqhole/haruspex": "file:../../lib/haruspex/ts"
25
+ "@freqhole/haruspex": "file:../../lib/haruspex/ts",
26
+ "zod": "^4.3.5"
27
27
  },
28
28
  "devDependencies": {
29
+ "@emnapi/core": "^1.11.3",
30
+ "@emnapi/runtime": "^1.11.3",
29
31
  "@tauri-apps/api": "^2.10.1",
30
32
  "@types/node": "^25.0.8",
31
33
  "tsx": "^4.7.0",
32
- "vitest": "^2.1.0",
33
34
  "typescript": "^5.3.0",
34
- "vite": "^5.4.21"
35
+ "vite": "^5.4.21",
36
+ "vitest": "^2.1.0"
35
37
  }
36
38
  }
@@ -5,12 +5,7 @@
5
5
  // uses Tauri's asset protocol for blob/audio file access (no HTTP streaming).
6
6
 
7
7
  import type { Transport, TransportResponse, BlobData } from "./transport.js";
8
- import type {
9
- CloseReason,
10
- EventFilter,
11
- JobEvent,
12
- JobStateSnapshot,
13
- } from "./codegen/schema.js";
8
+ import type { CloseReason, EventFilter, JobEvent, JobStateSnapshot } from "./codegen/schema.js";
14
9
 
15
10
  // tauri invoke function type
16
11
  type InvokeFn = (cmd: string, args?: unknown) => Promise<unknown>;
@@ -31,6 +26,16 @@ let convertFileSrc: ((path: string) => string) | null = null;
31
26
  // blob: object url path below.
32
27
  const isLinuxWebKit = typeof navigator !== "undefined" && navigator.userAgent.includes("Linux");
33
28
 
29
+ /**
30
+ * blobs that must never be buffered into memory as a blob: URL, even on
31
+ * linux: a local video can easily be multi-GB, and `fetch().arrayBuffer()`
32
+ * on one hangs the whole app. these stream from asset:// instead, which
33
+ * also gives `<video>` real range requests for seeking.
34
+ */
35
+ function isStreamableMime(mime?: string | null): boolean {
36
+ return (mime ?? "").startsWith("video/");
37
+ }
38
+
34
39
  /**
35
40
  * initialize tauri invoke function
36
41
  */
@@ -94,6 +99,13 @@ export class CharnelLocalTransport implements Transport {
94
99
  }
95
100
  }
96
101
 
102
+ // temporary diagnostic: log the exact payload sent over IPC for
103
+ // query-style routes, to rule out serialization loss between the
104
+ // caller and the tauri invoke() call (see tauri tag-filter bug).
105
+ if (path.includes("/query")) {
106
+ console.info(`[CharnelLocalTransport] -> ${path}`, jsonBody);
107
+ }
108
+
97
109
  // temporary boot-timing instrumentation (see slow-tauri-boot investigation) —
98
110
  // logs elapsed ms per IPC round-trip so a slow "loading..." screen can be
99
111
  // narrowed down to a specific api call instead of guessing.
@@ -149,11 +161,22 @@ export class CharnelLocalTransport implements Transport {
149
161
 
150
162
  /**
151
163
  * upload - converts FormData to base64 JSON and calls dispatch
152
- *
164
+ *
153
165
  * for tauri-local, we use wait_for_completion to block until the job
154
166
  * finishes, avoiding the need for polling from the client side.
155
167
  */
156
- async upload(path: string, formData: FormData): Promise<TransportResponse> {
168
+ async upload(
169
+ path: string,
170
+ formData: FormData,
171
+ _onProgress?: (loaded: number, total: number) => void,
172
+ ): Promise<TransportResponse> {
173
+ // no byte-level progress possible here - unlike CharnelTransport (P2P),
174
+ // which already chunks the file for android/memory reasons and can
175
+ // report progress per chunk, this local-dispatch path reads the whole
176
+ // file into one base64 JSON body and sends it as a single IPC call.
177
+ // chunking this too would need a new local (non-P2P) equivalent of the
178
+ // `-by-blake3` upload route, since that route currently requires a
179
+ // `node_id` and always pulls from a remote peer.
157
180
  // extract file and other fields from FormData
158
181
  const file = formData.get("file") as File | null;
159
182
  if (!file) {
@@ -170,7 +193,7 @@ export class CharnelLocalTransport implements Transport {
170
193
  // read file as base64
171
194
  const arrayBuffer = await file.arrayBuffer();
172
195
  const base64 = btoa(
173
- new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), "")
196
+ new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), ""),
174
197
  );
175
198
 
176
199
  // build JSON body for dispatch
@@ -249,11 +272,13 @@ export class CharnelLocalTransport implements Transport {
249
272
  this.blobPathCache.set(blobId, pathInfo);
250
273
  }
251
274
  }
252
-
275
+
253
276
  // check for no_local_path error - fall back to /data endpoint
254
277
  if (!pathInfo) {
255
278
  const parsed = JSON.parse(response.body);
256
- const isNoLocalPath = parsed.errors?.some((e: { error_type: string }) => e.error_type === "no_local_path");
279
+ const isNoLocalPath = parsed.errors?.some(
280
+ (e: { error_type: string }) => e.error_type === "no_local_path",
281
+ );
257
282
  if (isNoLocalPath || response.status !== 200) {
258
283
  return this.fetchBlobData(blobId);
259
284
  }
@@ -285,26 +310,26 @@ export class CharnelLocalTransport implements Transport {
285
310
  if (response.status !== 200) {
286
311
  throw new Error(`failed to get blob data: ${response.body}`);
287
312
  }
288
-
313
+
289
314
  const parsed = JSON.parse(response.body);
290
315
  if (!parsed.data?.data) {
291
316
  throw new Error(`blob data not found: ${blobId}`);
292
317
  }
293
-
318
+
294
319
  // decode base64 to Uint8Array
295
320
  const binaryString = atob(parsed.data.data);
296
321
  const bytes = new Uint8Array(binaryString.length);
297
322
  for (let i = 0; i < binaryString.length; i++) {
298
323
  bytes[i] = binaryString.charCodeAt(i);
299
324
  }
300
-
325
+
301
326
  const mime = parsed.data.mime ?? "application/octet-stream";
302
-
327
+
303
328
  // create and cache object URL for future use
304
329
  const blob = new Blob([bytes], { type: mime });
305
330
  const objectUrl = URL.createObjectURL(blob);
306
331
  this.blobObjectUrlCache.set(blobId, objectUrl);
307
-
332
+
308
333
  return {
309
334
  data: bytes,
310
335
  contentType: mime,
@@ -318,6 +343,10 @@ export class CharnelLocalTransport implements Transport {
318
343
  * 3. linux fallback: fetch via asset:// and wrap in a blob: object URL
319
344
  * (webkitgtk can't stream asset:// into `<audio>`)
320
345
  *
346
+ * the linux fallback buffers the ENTIRE file in memory, so it is never
347
+ * used for video - a multi-GB local video would exhaust memory and hang
348
+ * the app (`<video>` also seeks, which a one-shot blob can't stream).
349
+ *
321
350
  * note: when the rodio audio backend is enabled (charnel + opt-in)
322
351
  * playback bypasses html `<audio>` entirely and reads files via
323
352
  * filesystem path — this method is unused for that path.
@@ -331,14 +360,16 @@ export class CharnelLocalTransport implements Transport {
331
360
  }
332
361
 
333
362
  // on linux without rodio, we MUST go async to wrap in a blob:
334
- // url (asset:// can't stream into <audio> on webkitgtk).
335
- if (isLinuxWebKit) {
363
+ // url (asset:// can't stream into <audio> on webkitgtk) - except for
364
+ // video, which streams from asset:// directly (see below).
365
+ const cachedPath = this.blobPathCache.get(blobId);
366
+ if (isLinuxWebKit && !isStreamableMime(cachedPath?.mime)) {
336
367
  console.debug(`[CharnelLocalTransport] blob ${blobId}: linux fallback (async)`);
337
368
  return this.getBlobUrlAsync(blobId);
338
369
  }
339
370
 
340
371
  // check path cache (filesystem blobs) — direct asset:// url
341
- const cached = this.blobPathCache.get(blobId);
372
+ const cached = cachedPath;
342
373
  if (cached && convertFileSrc) {
343
374
  const url = convertFileSrc(cached.path);
344
375
  console.debug(`[CharnelLocalTransport] blob ${blobId}: asset:// (cached) -> ${url}`);
@@ -358,7 +389,7 @@ export class CharnelLocalTransport implements Transport {
358
389
  await ensureInvoke(); // ensure convertFileSrc is loaded
359
390
 
360
391
  const response = await this.request("GET", `/api/blobs/${blobId}/path`, undefined);
361
-
392
+
362
393
  if (response.status === 200) {
363
394
  const parsed = JSON.parse(response.body);
364
395
  if (parsed.data?.path) {
@@ -376,18 +407,24 @@ export class CharnelLocalTransport implements Transport {
376
407
  // - other (images / waveforms / cover art): per-blob cache so
377
408
  // multiple `<img>` and css `background-image` urls coexist
378
409
  // across the playerbar, queue sidebar, etc.
379
- if (isLinuxWebKit) {
410
+ // video is excluded - buffering it would hang the app.
411
+ if (isLinuxWebKit && !isStreamableMime(parsed.data.mime)) {
380
412
  return this.createBlobObjectUrl(blobId, parsed.data.path, parsed.data.mime);
381
413
  }
382
414
 
415
+ console.debug(
416
+ `[CharnelLocalTransport] blob ${blobId}: asset:// stream (mime=${parsed.data.mime ?? "?"})`,
417
+ );
383
418
  return convertFileSrc(parsed.data.path);
384
419
  }
385
420
  }
386
-
421
+
387
422
  // check for no_local_path - fall back to /data endpoint
388
423
  const parsed = JSON.parse(response.body);
389
- const isNoLocalPath = parsed.errors?.some((e: { error_type: string }) => e.error_type === "no_local_path");
390
-
424
+ const isNoLocalPath = parsed.errors?.some(
425
+ (e: { error_type: string }) => e.error_type === "no_local_path",
426
+ );
427
+
391
428
  if (isNoLocalPath) {
392
429
  // fetch blob data and create object URL
393
430
  await this.fetchBlobData(blobId);
@@ -396,7 +433,7 @@ export class CharnelLocalTransport implements Transport {
396
433
  return objectUrl;
397
434
  }
398
435
  }
399
-
436
+
400
437
  throw new Error(`failed to get blob path: ${response.body}`);
401
438
  }
402
439
 
@@ -415,7 +452,11 @@ export class CharnelLocalTransport implements Transport {
415
452
  * css `background-image` references coexist without one
416
453
  * revoking another.
417
454
  */
418
- private async createBlobObjectUrl(blobId: string, localPath: string, mime?: string): Promise<string> {
455
+ private async createBlobObjectUrl(
456
+ blobId: string,
457
+ localPath: string,
458
+ mime?: string,
459
+ ): Promise<string> {
419
460
  if (!convertFileSrc) {
420
461
  throw new Error("convertFileSrc not available");
421
462
  }
@@ -465,10 +506,7 @@ export class CharnelLocalTransport implements Transport {
465
506
  return out;
466
507
  }
467
508
 
468
- subscribeJobEvents(
469
- filter?: EventFilter,
470
- signal?: AbortSignal,
471
- ): AsyncIterable<JobEvent> {
509
+ subscribeJobEvents(filter?: EventFilter, signal?: AbortSignal): AsyncIterable<JobEvent> {
472
510
  return charnelJobEventsIterable(filter, signal);
473
511
  }
474
512
  }
@@ -509,9 +547,7 @@ export function createCharnelLocalTransport(baseUrl: string): CharnelLocalTransp
509
547
  * `EventsServerMsg` from `events_protocol.rs` but flattened — no
510
548
  * per-bistream correlation id (per-channel on tauri makes it unneeded).
511
549
  */
512
- type JobsEventsFrame =
513
- | { kind: "event"; evt: JobEvent }
514
- | { kind: "closed"; reason: CloseReason };
550
+ type JobsEventsFrame = { kind: "event"; evt: JobEvent } | { kind: "closed"; reason: CloseReason };
515
551
 
516
552
  async function* charnelJobEventsIterable(
517
553
  filter: EventFilter | undefined,
@@ -574,8 +610,7 @@ async function* charnelJobEventsIterable(
574
610
  // surface the close reason as a typed error so consumers can
575
611
  // distinguish `Lagged` (re-snapshot + reconnect) from other
576
612
  // terminal conditions.
577
- const reasonKind =
578
- (frame.reason as { kind: string }).kind ?? "internal";
613
+ const reasonKind = (frame.reason as { kind: string }).kind ?? "internal";
579
614
  if (reasonKind === "client_unsubscribed") return;
580
615
  throw new JobEventsStreamClosed(frame.reason);
581
616
  }