@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,598 @@
1
+ // CharnelLocalTransport - local grimoire dispatch via Tauri IPC
2
+ //
3
+ // bypasses HTTP entirely for local server access in Tauri apps.
4
+ // calls grimoire::api::dispatch directly via the api_call IPC command.
5
+ // uses Tauri's asset protocol for blob/audio file access (no HTTP streaming).
6
+
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";
14
+
15
+ // tauri invoke function type
16
+ type InvokeFn = (cmd: string, args?: unknown) => Promise<unknown>;
17
+
18
+ // tauri invoke is dynamically imported to avoid bundling in browser builds
19
+ let invoke: InvokeFn | null = null;
20
+ let convertFileSrc: ((path: string) => string) | null = null;
21
+
22
+ // webkitgtk (linux) can't play asset:// URLs in <audio> elements.
23
+ // detect once at module level so we can use blob: URLs as a workaround.
24
+ //
25
+ // historically there was a second workaround here — an embedded http
26
+ // loopback server (`media_server_info` ipc + `server::media_server`)
27
+ // that served blobs via plain http. it has been removed in favor of
28
+ // the rodio backend (see `client/spume/src/music/services/audio/`),
29
+ // which bypasses the html `<audio>` element entirely on linux. when
30
+ // rodio is *not* enabled and we're on linux, we fall back to the
31
+ // blob: object url path below.
32
+ const isLinuxWebKit = typeof navigator !== "undefined" && navigator.userAgent.includes("Linux");
33
+
34
+ /**
35
+ * initialize tauri invoke function
36
+ */
37
+ async function ensureInvoke(): Promise<InvokeFn> {
38
+ if (invoke) return invoke;
39
+ try {
40
+ const tauri = await import("@tauri-apps/api/core");
41
+ invoke = tauri.invoke as InvokeFn;
42
+ // also grab convertFileSrc for blob URLs
43
+ convertFileSrc = tauri.convertFileSrc;
44
+ return invoke;
45
+ } catch {
46
+ throw new Error("@tauri-apps/api not available - not running in Tauri");
47
+ }
48
+ }
49
+
50
+ /**
51
+ * response shape from api_call command (matches GrimoireResponse)
52
+ */
53
+ interface ApiResponse {
54
+ success: boolean;
55
+ message: string;
56
+ data?: unknown;
57
+ errors?: Array<{ error_type: string; title: string; detail: string }>;
58
+ }
59
+
60
+ /**
61
+ * CharnelLocalTransport - local dispatch via Tauri IPC
62
+ *
63
+ * implements Transport interface for use with FreqholeClient.
64
+ * routes requests through grimoire::api::dispatch via the api_call command.
65
+ * uses Tauri's asset protocol for blob/audio file access.
66
+ */
67
+ export class CharnelLocalTransport implements Transport {
68
+ // cache blob paths to avoid repeated IPC calls
69
+ private blobPathCache = new Map<string, { path: string; mime?: string }>();
70
+ // cache object URLs for db-stored blobs (no local path)
71
+ private blobObjectUrlCache = new Map<string, string>();
72
+ // single audio blob URL for linux workaround (revoke-on-replace to avoid memory leak)
73
+ private audioBlobUrl: { blobId: string; url: string } | null = null;
74
+
75
+ constructor(_baseUrl: string) {
76
+ // baseUrl no longer needed - all requests go through IPC.
77
+ // (used to also kick off an embedded http loopback server probe
78
+ // here; that server has been removed in favor of the rodio backend.)
79
+ }
80
+
81
+ /**
82
+ * make an API request via dispatch
83
+ */
84
+ async request(_method: string, path: string, body?: string): Promise<TransportResponse> {
85
+ const inv = await ensureInvoke();
86
+
87
+ // parse body string to JSON value (or null for no body)
88
+ let jsonBody: unknown = null;
89
+ if (body) {
90
+ try {
91
+ jsonBody = JSON.parse(body);
92
+ } catch {
93
+ jsonBody = {};
94
+ }
95
+ }
96
+
97
+ try {
98
+ // call dispatch via IPC
99
+ const response = (await inv("api_call", {
100
+ path,
101
+ body: jsonBody,
102
+ })) as ApiResponse;
103
+
104
+ // check for route_not_found - this is now a real error
105
+ const errorType = response.errors?.[0]?.error_type;
106
+ if (!response.success && errorType === "route_not_found") {
107
+ console.error(`[CharnelLocalTransport] route not found: ${path}`);
108
+ return {
109
+ status: 404,
110
+ body: JSON.stringify(response),
111
+ };
112
+ }
113
+
114
+ // return the full GrimoireResponse - same format as HTTP
115
+ // FreqholeClient expects { data: ... } wrapper and extracts .data
116
+ if (response.success) {
117
+ return {
118
+ status: 200,
119
+ body: JSON.stringify(response),
120
+ };
121
+ } else {
122
+ // map error to HTTP-like status
123
+ const status = errorTypeToStatus(errorType ?? "internal_error");
124
+ return {
125
+ status,
126
+ body: JSON.stringify(response),
127
+ };
128
+ }
129
+ } catch (err) {
130
+ // IPC error - treat as network error
131
+ return {
132
+ status: 0,
133
+ body: JSON.stringify({
134
+ error: err instanceof Error ? err.message : "IPC error",
135
+ }),
136
+ };
137
+ }
138
+ }
139
+
140
+ /**
141
+ * upload - converts FormData to base64 JSON and calls dispatch
142
+ *
143
+ * for tauri-local, we use wait_for_completion to block until the job
144
+ * finishes, avoiding the need for polling from the client side.
145
+ */
146
+ async upload(path: string, formData: FormData): Promise<TransportResponse> {
147
+ // extract file and other fields from FormData
148
+ const file = formData.get("file") as File | null;
149
+ if (!file) {
150
+ return {
151
+ status: 400,
152
+ body: JSON.stringify({
153
+ success: false,
154
+ message: "no file provided",
155
+ errors: [{ error_type: "bad_request", title: "bad request", detail: "no file provided" }],
156
+ }),
157
+ };
158
+ }
159
+
160
+ // read file as base64
161
+ const arrayBuffer = await file.arrayBuffer();
162
+ const base64 = btoa(
163
+ new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), "")
164
+ );
165
+
166
+ // build JSON body for dispatch
167
+ const body: Record<string, unknown> = {
168
+ data: base64,
169
+ filename: file.name,
170
+ // tauri-local optimization: wait for job to complete instead of returning job_id
171
+ // this eliminates the need for client-side polling
172
+ wait_for_completion: true,
173
+ };
174
+
175
+ // include associate_with if present
176
+ const associationStr = formData.get("associate_with");
177
+ if (associationStr && typeof associationStr === "string") {
178
+ try {
179
+ body.associate_with = JSON.parse(associationStr);
180
+ } catch {
181
+ // ignore parse errors
182
+ }
183
+ }
184
+
185
+ // call through normal request path
186
+ return this.request("POST", path, JSON.stringify(body));
187
+ }
188
+
189
+ /**
190
+ * upload by filesystem path - skips base64 encoding
191
+ * uses tauri dialog plugin to get paths, passes directly to backend
192
+ */
193
+ async uploadByPath(
194
+ path: string,
195
+ filePath: string,
196
+ metadata?: Record<string, unknown>,
197
+ ): Promise<TransportResponse> {
198
+ const body: Record<string, unknown> = {
199
+ file_path: filePath,
200
+ wait_for_completion: true,
201
+ ...metadata,
202
+ };
203
+
204
+ return this.request("POST", path, JSON.stringify(body));
205
+ }
206
+
207
+ /**
208
+ * fetch blob - get file path via IPC, convert to asset URL, fetch via browser
209
+ * falls back to /api/blobs/{id}/data for db-stored blobs without local paths
210
+ *
211
+ * `blobId` MUST be a `media_blobz.id` short pk (7-16 hex chars).
212
+ * sha256 / blake3 hashes are NOT valid here - they will hit the
213
+ * `id = ?` lookup, miss, and return "blob not found". if the
214
+ * caller only has a sha256, they need to resolve it to a
215
+ * media_blob_id first (e.g. via the song record) before calling.
216
+ */
217
+ async fetchBlob(blobId: string, _blake3?: string): Promise<BlobData> {
218
+ await ensureInvoke();
219
+
220
+ // check if we have a cached object URL (db-stored blob)
221
+ const cachedObjectUrl = this.blobObjectUrlCache.get(blobId);
222
+ if (cachedObjectUrl) {
223
+ const fetchResponse = await fetch(cachedObjectUrl);
224
+ const arrayBuffer = await fetchResponse.arrayBuffer();
225
+ return {
226
+ data: new Uint8Array(arrayBuffer),
227
+ contentType: fetchResponse.headers.get("content-type") ?? "application/octet-stream",
228
+ };
229
+ }
230
+
231
+ // try to get path from cache or IPC
232
+ let pathInfo = this.blobPathCache.get(blobId);
233
+ if (!pathInfo) {
234
+ const response = await this.request("GET", `/api/blobs/${blobId}/path`, undefined);
235
+ if (response.status === 200) {
236
+ const parsed = JSON.parse(response.body);
237
+ if (parsed.data?.path) {
238
+ pathInfo = { path: parsed.data.path, mime: parsed.data.mime };
239
+ this.blobPathCache.set(blobId, pathInfo);
240
+ }
241
+ }
242
+
243
+ // check for no_local_path error - fall back to /data endpoint
244
+ if (!pathInfo) {
245
+ const parsed = JSON.parse(response.body);
246
+ const isNoLocalPath = parsed.errors?.some((e: { error_type: string }) => e.error_type === "no_local_path");
247
+ if (isNoLocalPath || response.status !== 200) {
248
+ return this.fetchBlobData(blobId);
249
+ }
250
+ throw new Error(`failed to get blob path: ${response.body}`);
251
+ }
252
+ }
253
+
254
+ if (!convertFileSrc) {
255
+ throw new Error("convertFileSrc not available");
256
+ }
257
+
258
+ // convert to asset URL and fetch via browser
259
+ const assetUrl = convertFileSrc(pathInfo.path);
260
+ const fetchResponse = await fetch(assetUrl);
261
+ const arrayBuffer = await fetchResponse.arrayBuffer();
262
+
263
+ return {
264
+ data: new Uint8Array(arrayBuffer),
265
+ contentType: pathInfo.mime ?? "application/octet-stream",
266
+ };
267
+ }
268
+
269
+ /**
270
+ * fetch blob data via /api/blobs/{id}/data - for db-stored blobs
271
+ * returns base64-decoded data and creates/caches object URL
272
+ */
273
+ private async fetchBlobData(blobId: string): Promise<BlobData> {
274
+ const response = await this.request("GET", `/api/blobs/${blobId}/data`, undefined);
275
+ if (response.status !== 200) {
276
+ throw new Error(`failed to get blob data: ${response.body}`);
277
+ }
278
+
279
+ const parsed = JSON.parse(response.body);
280
+ if (!parsed.data?.data) {
281
+ throw new Error(`blob data not found: ${blobId}`);
282
+ }
283
+
284
+ // decode base64 to Uint8Array
285
+ const binaryString = atob(parsed.data.data);
286
+ const bytes = new Uint8Array(binaryString.length);
287
+ for (let i = 0; i < binaryString.length; i++) {
288
+ bytes[i] = binaryString.charCodeAt(i);
289
+ }
290
+
291
+ const mime = parsed.data.mime ?? "application/octet-stream";
292
+
293
+ // create and cache object URL for future use
294
+ const blob = new Blob([bytes], { type: mime });
295
+ const objectUrl = URL.createObjectURL(blob);
296
+ this.blobObjectUrlCache.set(blobId, objectUrl);
297
+
298
+ return {
299
+ data: bytes,
300
+ contentType: mime,
301
+ };
302
+ }
303
+
304
+ /**
305
+ * get blob URL — preference order:
306
+ * 1. cached object URL (db-stored blobs)
307
+ * 2. tauri asset:// (via `convertFileSrc`) on macos/windows
308
+ * 3. linux fallback: fetch via asset:// and wrap in a blob: object URL
309
+ * (webkitgtk can't stream asset:// into `<audio>`)
310
+ *
311
+ * note: when the rodio audio backend is enabled (charnel + opt-in)
312
+ * playback bypasses html `<audio>` entirely and reads files via
313
+ * filesystem path — this method is unused for that path.
314
+ */
315
+ getBlobUrl(blobId: string, _blake3?: string): string | Promise<string> {
316
+ // check object URL cache first (db-stored blobs)
317
+ const cachedObjectUrl = this.blobObjectUrlCache.get(blobId);
318
+ if (cachedObjectUrl) {
319
+ console.debug(`[CharnelLocalTransport] blob ${blobId}: object-url cache`);
320
+ return cachedObjectUrl;
321
+ }
322
+
323
+ // on linux without rodio, we MUST go async to wrap in a blob:
324
+ // url (asset:// can't stream into <audio> on webkitgtk).
325
+ if (isLinuxWebKit) {
326
+ console.debug(`[CharnelLocalTransport] blob ${blobId}: linux fallback (async)`);
327
+ return this.getBlobUrlAsync(blobId);
328
+ }
329
+
330
+ // check path cache (filesystem blobs) — direct asset:// url
331
+ const cached = this.blobPathCache.get(blobId);
332
+ if (cached && convertFileSrc) {
333
+ const url = convertFileSrc(cached.path);
334
+ console.debug(`[CharnelLocalTransport] blob ${blobId}: asset:// (cached) -> ${url}`);
335
+ return url;
336
+ }
337
+
338
+ // need to fetch path (or data) first
339
+ // console.debug(`[CharnelLocalTransport] blob ${blobId}: async path lookup`);
340
+ return this.getBlobUrlAsync(blobId);
341
+ }
342
+
343
+ /**
344
+ * async version of getBlobUrl for uncached blobs
345
+ * tries path first, falls back to /data for db-stored blobs
346
+ */
347
+ private async getBlobUrlAsync(blobId: string): Promise<string> {
348
+ await ensureInvoke(); // ensure convertFileSrc is loaded
349
+
350
+ const response = await this.request("GET", `/api/blobs/${blobId}/path`, undefined);
351
+
352
+ if (response.status === 200) {
353
+ const parsed = JSON.parse(response.body);
354
+ if (parsed.data?.path) {
355
+ // cache path for future use
356
+ this.blobPathCache.set(blobId, { path: parsed.data.path, mime: parsed.data.mime });
357
+
358
+ if (!convertFileSrc) {
359
+ throw new Error("convertFileSrc not available");
360
+ }
361
+
362
+ // on linux without media server: fall back to blob: workaround.
363
+ // routes audio + image differently:
364
+ // - audio: single-slot cache (revoke-on-replace) since audio
365
+ // blobs are large and we only play one at a time
366
+ // - other (images / waveforms / cover art): per-blob cache so
367
+ // multiple `<img>` and css `background-image` urls coexist
368
+ // across the playerbar, queue sidebar, etc.
369
+ if (isLinuxWebKit) {
370
+ return this.createBlobObjectUrl(blobId, parsed.data.path, parsed.data.mime);
371
+ }
372
+
373
+ return convertFileSrc(parsed.data.path);
374
+ }
375
+ }
376
+
377
+ // check for no_local_path - fall back to /data endpoint
378
+ const parsed = JSON.parse(response.body);
379
+ const isNoLocalPath = parsed.errors?.some((e: { error_type: string }) => e.error_type === "no_local_path");
380
+
381
+ if (isNoLocalPath) {
382
+ // fetch blob data and create object URL
383
+ await this.fetchBlobData(blobId);
384
+ const objectUrl = this.blobObjectUrlCache.get(blobId);
385
+ if (objectUrl) {
386
+ return objectUrl;
387
+ }
388
+ }
389
+
390
+ throw new Error(`failed to get blob path: ${response.body}`);
391
+ }
392
+
393
+ /**
394
+ * create a blob: object URL by fetching via asset:// protocol.
395
+ * used on linux where webkitgtk can't play asset:// in `<audio>`
396
+ * elements (and historically also where the embedded http loopback
397
+ * server stood in for the same workaround).
398
+ *
399
+ * mime-aware caching:
400
+ * - `audio/*`: single-slot cache, revoke-on-replace. audio blobs
401
+ * are large (often tens of MB) and only one ever plays at a
402
+ * time, so leaking the rest is wasteful.
403
+ * - everything else (images, waveforms, cover art): stored in
404
+ * `blobObjectUrlCache` keyed by blob id so multiple `<img>` /
405
+ * css `background-image` references coexist without one
406
+ * revoking another.
407
+ */
408
+ private async createBlobObjectUrl(blobId: string, localPath: string, mime?: string): Promise<string> {
409
+ if (!convertFileSrc) {
410
+ throw new Error("convertFileSrc not available");
411
+ }
412
+
413
+ const isAudio = (mime ?? "").startsWith("audio/");
414
+ const effectiveMime = mime ?? (isAudio ? "audio/mpeg" : "application/octet-stream");
415
+
416
+ const assetUrl = convertFileSrc(localPath);
417
+ const resp = await fetch(assetUrl);
418
+ const arrayBuffer = await resp.arrayBuffer();
419
+ const blob = new Blob([arrayBuffer], { type: effectiveMime });
420
+ const objectUrl = URL.createObjectURL(blob);
421
+
422
+ if (isAudio) {
423
+ // revoke previous single-slot audio url (if any) so we don't
424
+ // leak large buffers as the user moves between tracks.
425
+ if (this.audioBlobUrl) {
426
+ URL.revokeObjectURL(this.audioBlobUrl.url);
427
+ }
428
+ this.audioBlobUrl = { blobId, url: objectUrl };
429
+ } else {
430
+ // per-blob cache so e.g. the playerbar's waveform and the queue
431
+ // sidebar's matching waveform share a single object url.
432
+ this.blobObjectUrlCache.set(blobId, objectUrl);
433
+ }
434
+ return objectUrl;
435
+ }
436
+
437
+ // -----------------------------------------------------------------
438
+ // job events (local ipc shortcut)
439
+ //
440
+ // skips the iroh `freqhole-events/1` hop entirely when charnel is
441
+ // talking to the in-process grimoire server. uses the
442
+ // `jobs_events_snapshot` / `jobs_events_subscribe` /
443
+ // `jobs_events_unsubscribe` tauri commands.
444
+ //
445
+ // remote-targeting (open EVENTS_ALPN bistream against a peer) is
446
+ // deferred — when it lands, charnel will pick local-vs-remote based
447
+ // on the "currently-targeted remote peer" config.
448
+ // -----------------------------------------------------------------
449
+
450
+ async snapshotJobEvents(filter?: EventFilter): Promise<JobStateSnapshot[]> {
451
+ const inv = await ensureInvoke();
452
+ const out = (await inv("jobs_events_snapshot", {
453
+ filter: filter ?? null,
454
+ })) as JobStateSnapshot[];
455
+ return out;
456
+ }
457
+
458
+ subscribeJobEvents(
459
+ filter?: EventFilter,
460
+ signal?: AbortSignal,
461
+ ): AsyncIterable<JobEvent> {
462
+ return charnelJobEventsIterable(filter, signal);
463
+ }
464
+ }
465
+
466
+ /**
467
+ * map error_type to HTTP status code
468
+ */
469
+ function errorTypeToStatus(errorType: string): number {
470
+ switch (errorType) {
471
+ case "unauthorized":
472
+ return 401;
473
+ case "forbidden":
474
+ return 403;
475
+ case "not_found":
476
+ case "route_not_found":
477
+ return 404;
478
+ case "bad_request":
479
+ return 400;
480
+ default:
481
+ return 500;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * create a CharnelLocalTransport instance
487
+ * @param baseUrl - ignored (kept for API compatibility)
488
+ */
489
+ export function createCharnelLocalTransport(baseUrl: string): CharnelLocalTransport {
490
+ return new CharnelLocalTransport(baseUrl);
491
+ }
492
+
493
+ // =====================================================================
494
+ // job events iterator (tauri channel -> async iterable adapter)
495
+ // =====================================================================
496
+
497
+ /**
498
+ * frame shape emitted by the rust-side `JobsEventsFrame`. mirrors
499
+ * `EventsServerMsg` from `events_protocol.rs` but flattened — no
500
+ * per-bistream correlation id (per-channel on tauri makes it unneeded).
501
+ */
502
+ type JobsEventsFrame =
503
+ | { kind: "event"; evt: JobEvent }
504
+ | { kind: "closed"; reason: CloseReason };
505
+
506
+ async function* charnelJobEventsIterable(
507
+ filter: EventFilter | undefined,
508
+ signal: AbortSignal | undefined,
509
+ ): AsyncGenerator<JobEvent, void, void> {
510
+ const inv = await ensureInvoke();
511
+ // tauri `Channel` is constructed dynamically — same `@tauri-apps/api/core`
512
+ // module that exports `invoke`. dynamic import here mirrors the pattern
513
+ // above (the transport may also run in a non-tauri browser context where
514
+ // the module is absent).
515
+ const { Channel } = await import("@tauri-apps/api/core");
516
+
517
+ // bounded queue of frames the rust side has pushed but js hasn't
518
+ // consumed yet. wakers signal new arrivals.
519
+ const queue: JobsEventsFrame[] = [];
520
+ let wake: (() => void) | null = null;
521
+ let closed = false;
522
+ const waitForFrame = () =>
523
+ new Promise<void>((resolve) => {
524
+ wake = () => {
525
+ wake = null;
526
+ resolve();
527
+ };
528
+ });
529
+
530
+ const channel = new Channel<JobsEventsFrame>();
531
+ channel.onmessage = (frame: JobsEventsFrame) => {
532
+ queue.push(frame);
533
+ if (frame.kind === "closed") closed = true;
534
+ wake?.();
535
+ };
536
+
537
+ let sessionId: string;
538
+ try {
539
+ sessionId = (await inv("jobs_events_subscribe", {
540
+ filter: filter ?? null,
541
+ events: channel,
542
+ })) as string;
543
+ } catch (err) {
544
+ throw err instanceof Error ? err : new Error(String(err));
545
+ }
546
+
547
+ const onAbort = () => {
548
+ closed = true;
549
+ inv("jobs_events_unsubscribe", { sessionId }).catch(() => {});
550
+ wake?.();
551
+ };
552
+ signal?.addEventListener("abort", onAbort, { once: true });
553
+
554
+ try {
555
+ while (true) {
556
+ if (queue.length === 0) {
557
+ if (closed) return;
558
+ if (signal?.aborted) return;
559
+ await waitForFrame();
560
+ continue;
561
+ }
562
+ const frame = queue.shift()!;
563
+ if (frame.kind === "closed") {
564
+ // surface the close reason as a typed error so consumers can
565
+ // distinguish `Lagged` (re-snapshot + reconnect) from other
566
+ // terminal conditions.
567
+ const reasonKind =
568
+ (frame.reason as { kind: string }).kind ?? "internal";
569
+ if (reasonKind === "client_unsubscribed") return;
570
+ throw new JobEventsStreamClosed(frame.reason);
571
+ }
572
+ yield frame.evt;
573
+ }
574
+ } finally {
575
+ signal?.removeEventListener("abort", onAbort);
576
+ if (!closed) {
577
+ try {
578
+ await inv("jobs_events_unsubscribe", { sessionId });
579
+ } catch {
580
+ // session may have already been torn down server-side; nothing
581
+ // to do.
582
+ }
583
+ }
584
+ }
585
+ }
586
+
587
+ /**
588
+ * thrown by the subscribe iterator on any non-`client_unsubscribed`
589
+ * `CloseReason`. exported so consumers can `instanceof`-check before
590
+ * deciding to reconnect.
591
+ */
592
+ export class JobEventsStreamClosed extends Error {
593
+ constructor(public readonly reason: CloseReason) {
594
+ const kind = (reason as { kind: string }).kind ?? "internal";
595
+ super(`job events stream closed: ${kind}`);
596
+ this.name = "JobEventsStreamClosed";
597
+ }
598
+ }