@portalshq/capability-queue-broadcast 0.1.2

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/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # `@portalshq/capability-queue-broadcast`
2
+
3
+ Server-only producer API for one isolated Queue Broadcast Server endpoint.
4
+
5
+ The queue is deliberately separate from playback: a trusted application backend
6
+ uses this package to enqueue and observe jobs; its browser player gets only the
7
+ public HLS manifest returned by `getPlayback()`. Use
8
+ `@portalshq/capability-video-delivery`'s `HlsPlaybackSession` in a player or
9
+ another stream-delivery integration.
10
+
11
+ ## Backend flow
12
+
13
+ ```ts
14
+ import { QueueBroadcastClient } from "@portalshq/capability-queue-broadcast";
15
+
16
+ const broadcast = new QueueBroadcastClient({
17
+ endpoint: process.env.STREAMER_ENDPOINT!, // no token/query/fragment
18
+ token: process.env.STREAMER_API_TOKEN!, // backend secret only
19
+ });
20
+
21
+ const slotKey = `${generationRunId}:turn:1`;
22
+ const image = await broadcast.stageUpload({
23
+ mediaType: "image",
24
+ asset: { data: finishedImageBlob, filename: "scene.jpg", sha256: imageSha256 },
25
+ imageDuration: 18,
26
+ idempotencyKey: `${slotKey}:image`,
27
+ slotKey,
28
+ });
29
+ await broadcast.stageUpload({
30
+ mediaType: "audio",
31
+ asset: { data: finishedAudioBlob, filename: "narration.wav", sha256: audioSha256 },
32
+ idempotencyKey: `${slotKey}:audio`,
33
+ slotKey,
34
+ });
35
+ await broadcast.releaseSlot(slotKey);
36
+
37
+ for await (const update of broadcast.watchJob(image.id)) {
38
+ if (update.status === "failed") throw new Error(update.error ?? "queue job failed");
39
+ }
40
+
41
+ // Pass this descriptor to the application player; do not send `token` to it.
42
+ const playback = await broadcast.getPlayback();
43
+ ```
44
+
45
+ `enqueueUpload` is the preferred distributed ingestion path. A trusted backend
46
+ sends each completed `image`, `audio`, or `video` asset over authenticated
47
+ multipart HTTP to its separately running Queue Broadcast Server. The server
48
+ owns the bytes after receipt, checks the supplied SHA-256, and plays eligible
49
+ items in FIFO order. Standalone audio is valid for capability consumers; an
50
+ application may choose to discard it by policy.
51
+
52
+ For a multi-asset generation turn or scheduled pre-roll, call `stageUpload` for
53
+ each successful asset with the same `slotKey`, then call `releaseSlot(slotKey)`.
54
+ Release is idempotent and atomically makes all staged items FIFO-eligible, so a
55
+ partially generated turn cannot air early. Adjacent image/audio items can then
56
+ be composited by the Streamer; image-only and video items play independently.
57
+ The `QueueUploadAsset` SHA-256 field lets the server verify bytes without an
58
+ extra producer-side copy. Identical idempotency retries return the original job
59
+ receipt; conflicting reuse maps to `QueueBroadcastError` status `409`.
60
+
61
+ `enqueuePairUpload` and `stagePair` remain supported for legacy producers, but
62
+ new applications should use individual uploads and slots.
63
+
64
+ `enqueueUrl` remains for backwards-compatible server-to-server URL ingestion.
65
+ It is not the recommended path for applications that already have the finished
66
+ media bytes.
67
+
68
+ ## Availability preflight
69
+
70
+ Run this immediately before costly generation, archive recovery, or a direct
71
+ upload. `health` confirms that the Streamer's media service is ready;
72
+ `getPlayback` is authenticated and therefore also detects a bad control URL or
73
+ queue bearer token. Both accept an `AbortSignal`, so an operator stop or process
74
+ shutdown can cancel a pending availability check promptly.
75
+
76
+ ```ts
77
+ const controller = new AbortController();
78
+ const health = await broadcast.health({ signal: controller.signal });
79
+ if (!health.ok) throw new Error("Streamer media service is unavailable");
80
+
81
+ const playback = await broadcast.getPlayback({ signal: controller.signal });
82
+ // Generate or upload only after both probes have succeeded.
83
+ ```
84
+
85
+ Do not treat a queue-capacity response from a later upload as a preflight
86
+ failure: it means the service is reachable but has applied normal backpressure.
87
+
88
+ ## `massively-social-ebook` example
89
+
90
+ Its generation worker owns a per-channel `STREAMER_ENDPOINT` and secret. It
91
+ generates image and narration independently in memory, computes SHA-256,
92
+ archives canonical media independently, then stages the successful image and
93
+ optional narration under one slot before releasing it. If narration fails, it
94
+ releases the image alone; if the image fails, it discards lone narration. The chapter page obtains
95
+ `getPlayback()` from its backend and gives the resulting HLS manifest to its
96
+ existing HLS-capable player. The page never calls queue endpoints or receives
97
+ the bearer token.
98
+
99
+ For chat from another broadcast platform, pass normalized provider events to
100
+ `ExternalChatIngress` in `@portalshq/capability-realtime-fanout` with the same
101
+ endpoint. That produces the `chat:<normalized endpoint>` topic; platform OAuth,
102
+ webhooks, storage, and relays remain outside this capability.
103
+
104
+ ## Generated API
105
+
106
+ `src/generated/api.ts` is generated from the Streamer repository's checked-in
107
+ `openapi.json` contract using Orval:
108
+
109
+ ```bash
110
+ npm run generate -w @portalshq/capability-queue-broadcast
111
+ ```
112
+
113
+ Commit generated changes with the matching Streamer OpenAPI artifact. The
114
+ hand-written `QueueBroadcastClient` owns authentication, error mapping,
115
+ endpoint validation, and polling semantics.
116
+
117
+ ## RTMP frame delivery
118
+
119
+ `RTMPStreamer` accepts Base64-encoded JPEG frames (a `data:image/jpeg;base64,`
120
+ prefix is also accepted) and pipes decoded JPEGs to FFmpeg. Set `ffmpegPath`
121
+ when the executable is not available as `ffmpeg` on `PATH`. The streamer owns
122
+ one RTMP destination, bounded buffering, process shutdown, and health status;
123
+ the consuming application owns generation and starts a separate instance for
124
+ each destination.
125
+
126
+ `GenerationContext.textOverlay` configures FFmpeg's `drawtext` filter. Pass the
127
+ same configuration to `RTMPStreamer` and set `audioDurationSeconds` from the
128
+ generated audio: the overlay is visible for the audio duration plus 1.5 seconds
129
+ at both the head and tail. With no audio it remains visible for five seconds.
130
+ The deployment FFmpeg build must include the `drawtext` filter (libfreetype);
131
+ set `fontFile` when it has no usable default font.
@@ -0,0 +1,174 @@
1
+ import type { HlsPlaybackSession } from "@portalshq/capability-video-delivery";
2
+ import type { ChatMessage } from "@portalshq/capability-realtime-fanout";
3
+ import type { HealthResponse, JobResponse, JobResponseMediaType, JobResponseStatus, QueuePairResponse, QueueSlotResponse } from "./generated/api.js";
4
+ export type QueueMediaType = JobResponseMediaType;
5
+ export type QueueJobStatus = JobResponseStatus;
6
+ export type QueueBroadcastJob = JobResponse;
7
+ export type QueueBroadcastHealth = HealthResponse;
8
+ export type QueueBroadcastPair = QueuePairResponse;
9
+ export type QueueBroadcastSlot = QueueSlotResponse;
10
+ export interface QueueBroadcastClientOptions {
11
+ /** The control-plane URL for one isolated Queue Broadcast Server instance. */
12
+ endpoint: string | URL;
13
+ /** Server-only bearer token. Never expose this value to a browser. */
14
+ token: string;
15
+ /** Injection point for tests or a server runtime with a custom fetch implementation. */
16
+ fetch?: typeof fetch;
17
+ /** Optional longer timeout policy for multipart media upload requests. */
18
+ uploadFetch?: typeof fetch;
19
+ }
20
+ export interface EnqueueUrlInput {
21
+ mediaType: QueueMediaType;
22
+ /** A completed, permitted media URL. Signed query strings are allowed here. */
23
+ url: string;
24
+ imageDuration?: number;
25
+ /** Required so producer retries are safe. */
26
+ idempotencyKey: string;
27
+ }
28
+ export interface WatchJobOptions {
29
+ signal?: AbortSignal;
30
+ intervalMs?: number;
31
+ onUpdate?: (job: QueueBroadcastJob) => void | Promise<void>;
32
+ }
33
+ /** A finished file held by the trusted producer process. */
34
+ export interface QueueUploadAsset {
35
+ /** Native Blob keeps the multipart request streamable across processes. */
36
+ data: Blob;
37
+ /** Metadata only; the streamer validates the actual bytes with sha256. */
38
+ filename: string;
39
+ /** SHA-256 of `data`, as lowercase hexadecimal. */
40
+ sha256: string;
41
+ }
42
+ export interface EnqueuePairUploadInput {
43
+ image: QueueUploadAsset;
44
+ audio: QueueUploadAsset;
45
+ /** The image's real composite duration, normally the narration duration. */
46
+ imageDuration: number;
47
+ /** Required, stable producer key. Retries return the original pair. */
48
+ idempotencyKey: string;
49
+ /** Persist the pair, but hold it from playout until `releasePair`. */
50
+ staged?: boolean;
51
+ }
52
+ /** One independently playable finished asset uploaded to the Streamer. */
53
+ export interface EnqueueUploadInput {
54
+ mediaType: QueueMediaType;
55
+ asset: QueueUploadAsset;
56
+ /** Optional image duration. Omit it to use the Streamer's configured default. */
57
+ imageDuration?: number;
58
+ /** Required, stable producer key. Retries return the original job. */
59
+ idempotencyKey: string;
60
+ /** Hold this item until its slot is atomically released. */
61
+ staged?: boolean;
62
+ /** Required when `staged` is true; identifies the producer's logical turn. */
63
+ slotKey?: string;
64
+ }
65
+ export type StageUploadInput = Omit<EnqueueUploadInput, "staged" | "slotKey"> & {
66
+ slotKey: string;
67
+ };
68
+ /** Text rendered by the streamer's FFmpeg `drawtext` filter. */
69
+ export interface TextOverlayConfig {
70
+ text: string;
71
+ position?: "top" | "center" | "bottom";
72
+ fontSize?: number;
73
+ fontColor?: string;
74
+ /** Optional absolute path for deployments that do not have a default font. */
75
+ fontFile?: string;
76
+ }
77
+ export interface GenerationContext {
78
+ /** Base64 encoded last frame from previous generation for visual continuity */
79
+ previousFrame?: string;
80
+ /** History of prompts used in previous generations */
81
+ previousPrompts: string[];
82
+ /** Chat messages available for context (if chat integration enabled) */
83
+ chatMessages?: ChatMessage[];
84
+ /** Optional text overlay to render in the RTMP stream. */
85
+ textOverlay?: TextOverlayConfig;
86
+ /** Generation parameters for the current request */
87
+ generationParams: Record<string, unknown>;
88
+ }
89
+ export interface GeneratedFrames {
90
+ /** Base64 encoded video frames */
91
+ frames: string[];
92
+ /** Optional audio data in PCM format */
93
+ audio?: ArrayBuffer;
94
+ /** Duration of the attached audio, used to time a text overlay. */
95
+ audioDurationSeconds?: number;
96
+ /** Duration of the generated content in seconds */
97
+ duration: number;
98
+ }
99
+ /**
100
+ * Callback interface for real-time video generation.
101
+ * The consuming application provides the AI implementation while queue-broadcast
102
+ * handles streaming infrastructure, frame buffering, and RTMP output.
103
+ */
104
+ export interface GenerationCallback {
105
+ (context: GenerationContext): Promise<GeneratedFrames>;
106
+ }
107
+ /** Error returned by the queue control plane, including its HTTP status. */
108
+ export declare class QueueBroadcastError extends Error {
109
+ readonly status: number;
110
+ readonly detail?: string | undefined;
111
+ constructor(message: string, status: number, detail?: string | undefined);
112
+ }
113
+ /**
114
+ * Canonicalize a Queue Broadcast Server endpoint before it becomes a queue or
115
+ * chat namespace. Credentials, query parameters, and fragments are not part
116
+ * of an endpoint identity and could otherwise leak secret or routing data.
117
+ */
118
+ export declare function normalizeBroadcastEndpoint(endpoint: string | URL): string;
119
+ /**
120
+ * Server-only producer client. It controls one broadcast endpoint while
121
+ * browser clients receive only `getPlayback().playbackManifestUrl`.
122
+ */
123
+ export declare class QueueBroadcastClient {
124
+ private readonly endpoint;
125
+ private readonly token;
126
+ private readonly requestFetch;
127
+ private readonly uploadRequestFetch;
128
+ constructor(options: QueueBroadcastClientOptions);
129
+ enqueueUrl(input: EnqueueUrlInput): Promise<QueueBroadcastJob>;
130
+ /**
131
+ * Upload one finished image, audio, or video asset. The Streamer owns its
132
+ * bytes and schedules it in FIFO order; adjacent image/audio jobs may be
133
+ * composited there when both are ready.
134
+ */
135
+ enqueueUpload(input: EnqueueUploadInput): Promise<QueueBroadcastJob>;
136
+ /** Persist one item under a turn slot; use `releaseSlot` after all successes. */
137
+ stageUpload(input: StageUploadInput): Promise<QueueBroadcastJob>;
138
+ /**
139
+ * Send finished media bytes directly to the remote Streamer process.
140
+ * This is the preferred ingestion path: the streamer does not download the
141
+ * asset from an application-owned URL, and it only exposes both jobs after
142
+ * its durable pair receipt has committed.
143
+ */
144
+ enqueuePairUpload(input: EnqueuePairUploadInput): Promise<QueueBroadcastPair>;
145
+ /** Persist a canonical pair during pre-roll, then activate it later. */
146
+ stagePair(input: Omit<EnqueuePairUploadInput, "staged">): Promise<QueueBroadcastPair>;
147
+ getPair(pairId: string, options?: {
148
+ signal?: AbortSignal;
149
+ }): Promise<QueueBroadcastPair>;
150
+ /** Idempotently make a previously staged pair eligible for FIFO playout. */
151
+ releasePair(pairId: string, options?: {
152
+ signal?: AbortSignal;
153
+ }): Promise<QueueBroadcastPair>;
154
+ getSlot(slotKey: string, options?: {
155
+ signal?: AbortSignal;
156
+ }): Promise<QueueBroadcastSlot>;
157
+ /** Idempotently make every staged item in a logical turn FIFO-eligible. */
158
+ releaseSlot(slotKey: string, options?: {
159
+ signal?: AbortSignal;
160
+ }): Promise<QueueBroadcastSlot>;
161
+ getJob(jobId: string, options?: {
162
+ signal?: AbortSignal;
163
+ }): Promise<QueueBroadcastJob>;
164
+ /** Watch a job without retaining a subscription or exposing queue tokens to a browser. */
165
+ watchJob(jobId: string, options?: WatchJobOptions): AsyncGenerator<QueueBroadcastJob>;
166
+ getPlayback(options?: {
167
+ signal?: AbortSignal;
168
+ }): Promise<HlsPlaybackSession>;
169
+ health(options?: {
170
+ signal?: AbortSignal;
171
+ }): Promise<QueueBroadcastHealth>;
172
+ private request;
173
+ private requestMultipart;
174
+ }
package/dist/client.js ADDED
@@ -0,0 +1,312 @@
1
+ /** Error returned by the queue control plane, including its HTTP status. */
2
+ export class QueueBroadcastError extends Error {
3
+ status;
4
+ detail;
5
+ constructor(message, status, detail) {
6
+ super(message);
7
+ this.status = status;
8
+ this.detail = detail;
9
+ this.name = "QueueBroadcastError";
10
+ }
11
+ }
12
+ /**
13
+ * Canonicalize a Queue Broadcast Server endpoint before it becomes a queue or
14
+ * chat namespace. Credentials, query parameters, and fragments are not part
15
+ * of an endpoint identity and could otherwise leak secret or routing data.
16
+ */
17
+ export function normalizeBroadcastEndpoint(endpoint) {
18
+ const url = new URL(endpoint.toString());
19
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
20
+ throw new TypeError("Queue Broadcast endpoint must use http or https");
21
+ }
22
+ if (url.username || url.password || url.search || url.hash) {
23
+ throw new TypeError("Queue Broadcast endpoint cannot contain credentials, query parameters, or a fragment");
24
+ }
25
+ url.pathname = url.pathname.replace(/\/+$/, "");
26
+ return url.toString().replace(/\/$/, "");
27
+ }
28
+ function validateSourceUrl(value) {
29
+ const url = new URL(value);
30
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
31
+ throw new TypeError("media url must use http or https");
32
+ }
33
+ if (url.username || url.password || url.hash) {
34
+ throw new TypeError("media url cannot contain credentials or a fragment");
35
+ }
36
+ }
37
+ function isTerminal(status) {
38
+ return status === "done" || status === "failed";
39
+ }
40
+ const SHA256_HEX = /^[a-f0-9]{64}$/;
41
+ function validateUploadAsset(asset, field) {
42
+ if (!(asset.data instanceof Blob) || asset.data.size <= 0) {
43
+ throw new TypeError(`${field}.data must be a non-empty Blob`);
44
+ }
45
+ if (!asset.filename.trim())
46
+ throw new TypeError(`${field}.filename is required`);
47
+ if (!SHA256_HEX.test(asset.sha256)) {
48
+ throw new TypeError(`${field}.sha256 must be a lowercase SHA-256 hex digest`);
49
+ }
50
+ }
51
+ function validateImageDuration(mediaType, imageDuration) {
52
+ if (mediaType === "image") {
53
+ if (imageDuration !== undefined && (!Number.isFinite(imageDuration) || imageDuration < 1 || imageDuration > 30)) {
54
+ throw new TypeError("imageDuration must be a finite number between 1 and 30 seconds");
55
+ }
56
+ }
57
+ else if (imageDuration !== undefined) {
58
+ throw new TypeError("imageDuration is only valid for image media");
59
+ }
60
+ }
61
+ function validateSlotKey(slotKey, staged) {
62
+ const normalized = slotKey?.trim();
63
+ if (staged && !normalized)
64
+ throw new TypeError("slotKey is required for staged uploads");
65
+ if (!staged && normalized)
66
+ throw new TypeError("slotKey is only valid for staged uploads");
67
+ if (normalized && normalized.length > 255)
68
+ throw new TypeError("slotKey must be at most 255 characters");
69
+ return normalized;
70
+ }
71
+ function waitForPoll(intervalMs, signal) {
72
+ return new Promise((resolve) => {
73
+ if (signal?.aborted) {
74
+ resolve();
75
+ return;
76
+ }
77
+ const onAbort = () => {
78
+ clearTimeout(timer);
79
+ resolve();
80
+ };
81
+ const timer = setTimeout(() => {
82
+ signal?.removeEventListener("abort", onAbort);
83
+ resolve();
84
+ }, intervalMs);
85
+ signal?.addEventListener("abort", onAbort, { once: true });
86
+ });
87
+ }
88
+ /**
89
+ * Server-only producer client. It controls one broadcast endpoint while
90
+ * browser clients receive only `getPlayback().playbackManifestUrl`.
91
+ */
92
+ export class QueueBroadcastClient {
93
+ endpoint;
94
+ token;
95
+ requestFetch;
96
+ uploadRequestFetch;
97
+ constructor(options) {
98
+ this.endpoint = normalizeBroadcastEndpoint(options.endpoint);
99
+ if (!options.token.trim()) {
100
+ throw new TypeError("Queue Broadcast bearer token is required");
101
+ }
102
+ this.token = options.token;
103
+ this.requestFetch = options.fetch ?? fetch;
104
+ this.uploadRequestFetch = options.uploadFetch ?? this.requestFetch;
105
+ }
106
+ async enqueueUrl(input) {
107
+ validateSourceUrl(input.url);
108
+ const idempotencyKey = input.idempotencyKey.trim();
109
+ if (!idempotencyKey) {
110
+ throw new TypeError("idempotencyKey is required");
111
+ }
112
+ validateImageDuration(input.mediaType, input.imageDuration);
113
+ return this.request("/v1/queue", {
114
+ method: "POST",
115
+ headers: { "Idempotency-Key": idempotencyKey },
116
+ body: JSON.stringify({
117
+ media_type: input.mediaType,
118
+ url: input.url,
119
+ ...(input.imageDuration === undefined ? {} : { duration: input.imageDuration }),
120
+ }),
121
+ });
122
+ }
123
+ /**
124
+ * Upload one finished image, audio, or video asset. The Streamer owns its
125
+ * bytes and schedules it in FIFO order; adjacent image/audio jobs may be
126
+ * composited there when both are ready.
127
+ */
128
+ async enqueueUpload(input) {
129
+ validateUploadAsset(input.asset, input.mediaType);
130
+ validateImageDuration(input.mediaType, input.imageDuration);
131
+ const idempotencyKey = input.idempotencyKey.trim();
132
+ if (!idempotencyKey)
133
+ throw new TypeError("idempotencyKey is required");
134
+ const slotKey = validateSlotKey(input.slotKey, input.staged);
135
+ const form = new FormData();
136
+ form.append("file", input.asset.data, input.asset.filename);
137
+ form.append("media_type", input.mediaType);
138
+ form.append("sha256", input.asset.sha256);
139
+ if (input.imageDuration !== undefined)
140
+ form.append("duration", String(input.imageDuration));
141
+ if (input.staged)
142
+ form.append("staged", "true");
143
+ if (slotKey)
144
+ form.append("slot_key", slotKey);
145
+ return this.requestMultipart("/v1/queue/upload", form, {
146
+ method: "POST",
147
+ headers: { "Idempotency-Key": idempotencyKey },
148
+ });
149
+ }
150
+ /** Persist one item under a turn slot; use `releaseSlot` after all successes. */
151
+ async stageUpload(input) {
152
+ return this.enqueueUpload({ ...input, staged: true });
153
+ }
154
+ /**
155
+ * Send finished media bytes directly to the remote Streamer process.
156
+ * This is the preferred ingestion path: the streamer does not download the
157
+ * asset from an application-owned URL, and it only exposes both jobs after
158
+ * its durable pair receipt has committed.
159
+ */
160
+ async enqueuePairUpload(input) {
161
+ validateUploadAsset(input.image, "image");
162
+ validateUploadAsset(input.audio, "audio");
163
+ const idempotencyKey = input.idempotencyKey.trim();
164
+ if (!idempotencyKey)
165
+ throw new TypeError("idempotencyKey is required");
166
+ if (!Number.isFinite(input.imageDuration) || input.imageDuration < 1 || input.imageDuration > 30) {
167
+ throw new TypeError("imageDuration must be a finite number between 1 and 30 seconds");
168
+ }
169
+ const form = new FormData();
170
+ form.append("image", input.image.data, input.image.filename);
171
+ form.append("audio", input.audio.data, input.audio.filename);
172
+ form.append("image_duration", String(input.imageDuration));
173
+ form.append("image_sha256", input.image.sha256);
174
+ form.append("audio_sha256", input.audio.sha256);
175
+ if (input.staged)
176
+ form.append("staged", "true");
177
+ return this.requestMultipart("/v1/queue/pairs/upload", form, {
178
+ method: "POST",
179
+ headers: { "Idempotency-Key": idempotencyKey },
180
+ });
181
+ }
182
+ /** Persist a canonical pair during pre-roll, then activate it later. */
183
+ async stagePair(input) {
184
+ return this.enqueuePairUpload({ ...input, staged: true });
185
+ }
186
+ async getPair(pairId, options = {}) {
187
+ if (!pairId.trim())
188
+ throw new TypeError("pairId is required");
189
+ return this.request(`/v1/queue/pairs/${encodeURIComponent(pairId)}`, options);
190
+ }
191
+ /** Idempotently make a previously staged pair eligible for FIFO playout. */
192
+ async releasePair(pairId, options = {}) {
193
+ if (!pairId.trim())
194
+ throw new TypeError("pairId is required");
195
+ return this.request(`/v1/queue/pairs/${encodeURIComponent(pairId)}/release`, {
196
+ ...options,
197
+ method: "POST",
198
+ });
199
+ }
200
+ async getSlot(slotKey, options = {}) {
201
+ const normalized = validateSlotKey(slotKey, true);
202
+ return this.request(`/v1/queue/slots/${encodeURIComponent(normalized)}`, options);
203
+ }
204
+ /** Idempotently make every staged item in a logical turn FIFO-eligible. */
205
+ async releaseSlot(slotKey, options = {}) {
206
+ const normalized = validateSlotKey(slotKey, true);
207
+ return this.request(`/v1/queue/slots/${encodeURIComponent(normalized)}/release`, {
208
+ ...options,
209
+ method: "POST",
210
+ });
211
+ }
212
+ async getJob(jobId, options = {}) {
213
+ if (!jobId.trim())
214
+ throw new TypeError("jobId is required");
215
+ return this.request(`/v1/queue/${encodeURIComponent(jobId)}`, options);
216
+ }
217
+ /** Watch a job without retaining a subscription or exposing queue tokens to a browser. */
218
+ async *watchJob(jobId, options = {}) {
219
+ const intervalMs = Math.max(50, Math.floor(options.intervalMs ?? 1_000));
220
+ let previousRevision;
221
+ while (!options.signal?.aborted) {
222
+ let job;
223
+ try {
224
+ job = await this.getJob(jobId, { signal: options.signal });
225
+ }
226
+ catch (error) {
227
+ if (options.signal?.aborted)
228
+ return;
229
+ throw error;
230
+ }
231
+ const revision = `${job.status}:${job.updated_at}:${job.error ?? ""}`;
232
+ if (revision !== previousRevision) {
233
+ previousRevision = revision;
234
+ await options.onUpdate?.(job);
235
+ yield job;
236
+ }
237
+ if (isTerminal(job.status) || options.signal?.aborted)
238
+ return;
239
+ await waitForPoll(intervalMs, options.signal);
240
+ }
241
+ }
242
+ async getPlayback(options = {}) {
243
+ const stream = await this.request("/v1/stream", options);
244
+ const hls = new URL(stream.hls);
245
+ if (hls.protocol !== "http:" && hls.protocol !== "https:") {
246
+ throw new QueueBroadcastError("queue server returned a non-http HLS URL", 502);
247
+ }
248
+ return { sessionId: this.endpoint, playbackManifestUrl: hls.toString() };
249
+ }
250
+ async health(options = {}) {
251
+ return this.request("/health", options, false);
252
+ }
253
+ async request(path, init = {}, authenticated = true) {
254
+ const headers = new Headers(init.headers);
255
+ headers.set("Accept", "application/json");
256
+ if (init.body !== undefined && !headers.has("Content-Type")) {
257
+ headers.set("Content-Type", "application/json");
258
+ }
259
+ if (authenticated)
260
+ headers.set("Authorization", `Bearer ${this.token}`);
261
+ let response;
262
+ try {
263
+ response = await this.requestFetch(`${this.endpoint}${path}`, { ...init, headers });
264
+ }
265
+ catch (cause) {
266
+ throw new QueueBroadcastError(`Queue Broadcast request failed: ${cause instanceof Error ? cause.message : String(cause)}`, 0);
267
+ }
268
+ const text = await response.text();
269
+ let data;
270
+ try {
271
+ data = text ? JSON.parse(text) : undefined;
272
+ }
273
+ catch {
274
+ data = undefined;
275
+ }
276
+ if (!response.ok) {
277
+ const detail = typeof data === "object" && data !== null && "detail" in data && typeof data.detail === "string"
278
+ ? data.detail
279
+ : text || response.statusText;
280
+ throw new QueueBroadcastError(`Queue Broadcast request failed (${response.status}): ${detail}`, response.status, detail);
281
+ }
282
+ return data;
283
+ }
284
+ async requestMultipart(path, body, init) {
285
+ const headers = new Headers(init.headers);
286
+ headers.set("Accept", "application/json");
287
+ headers.set("Authorization", `Bearer ${this.token}`);
288
+ // Do not set Content-Type: fetch supplies the multipart boundary.
289
+ let response;
290
+ try {
291
+ response = await this.uploadRequestFetch(`${this.endpoint}${path}`, { ...init, headers, body });
292
+ }
293
+ catch (cause) {
294
+ throw new QueueBroadcastError(`Queue Broadcast request failed: ${cause instanceof Error ? cause.message : String(cause)}`, 0);
295
+ }
296
+ const text = await response.text();
297
+ let data;
298
+ try {
299
+ data = text ? JSON.parse(text) : undefined;
300
+ }
301
+ catch {
302
+ data = undefined;
303
+ }
304
+ if (!response.ok) {
305
+ const detail = typeof data === "object" && data !== null && "detail" in data && typeof data.detail === "string"
306
+ ? data.detail
307
+ : text || response.statusText;
308
+ throw new QueueBroadcastError(`Queue Broadcast request failed (${response.status}): ${detail}`, response.status, detail);
309
+ }
310
+ return data;
311
+ }
312
+ }