@portalshq/capability-queue-broadcast 0.1.5 → 0.1.9

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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Server-only producer API for one isolated Queue Broadcast Server endpoint.
4
4
 
5
+ `ContentQueuePipeline` is the reusable producer preparation boundary. It
6
+ deduplicates channel/run/content identities, bounds concurrent preparation and
7
+ measured duration/bytes/count, accounts for shared visuals once, commits
8
+ results in enqueue order, and exposes staging/release/completion acknowledgments.
9
+ Applications provide content preparation and persistence callbacks.
10
+
5
11
  The queue is deliberately separate from playback: a trusted application backend
6
12
  uses this package to enqueue and observe jobs; its browser player gets only the
7
13
  public HLS manifest returned by `getPlayback()`. Use
@@ -103,14 +109,17 @@ webhooks, storage, and relays remain outside this capability.
103
109
 
104
110
  ## Generated API
105
111
 
106
- `src/generated/api.ts` is generated from the Streamer repository's checked-in
107
- `openapi.json` contract using Orval:
112
+ `src/generated/api.ts` is generated from the checked-in
113
+ `openapi/streamer.json` contract snapshot using Orval:
108
114
 
109
115
  ```bash
110
116
  npm run generate -w @portalshq/capability-queue-broadcast
111
117
  ```
112
118
 
113
- Commit generated changes with the matching Streamer OpenAPI artifact. The
119
+ Refresh `openapi/streamer.json` from the Streamer repository when updating the
120
+ client, and commit the snapshot with the generated changes. This makes release
121
+ tests independent of a sibling checkout. The current snapshot comes from
122
+ Streamer commit `90b9102` (`openapi.json`). The
114
123
  hand-written `QueueBroadcastClient` owns authentication, error mapping,
115
124
  endpoint validation, and polling semantics.
116
125
 
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { HlsPlaybackSession } from "@portalshq/capability-video-delivery";
1
+ import { type CaptionTrack, type HlsPlaybackSession } from "@portalshq/capability-video-delivery";
2
2
  import type { ChatMessage } from "@portalshq/capability-realtime-fanout";
3
3
  import type { HealthResponse, JobResponse, JobResponseMediaType, JobResponseStatus, QueuePairResponse, QueueSlotResponse } from "./generated/api.js";
4
4
  export type QueueMediaType = JobResponseMediaType;
@@ -165,6 +165,7 @@ export declare class QueueBroadcastClient {
165
165
  watchJob(jobId: string, options?: WatchJobOptions): AsyncGenerator<QueueBroadcastJob>;
166
166
  getPlayback(options?: {
167
167
  signal?: AbortSignal;
168
+ captionTracks?: readonly CaptionTrack[];
168
169
  }): Promise<HlsPlaybackSession>;
169
170
  health(options?: {
170
171
  signal?: AbortSignal;
package/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { normalizeCaptionTracks } from "@portalshq/capability-video-delivery";
1
2
  /** Error returned by the queue control plane, including its HTTP status. */
2
3
  export class QueueBroadcastError extends Error {
3
4
  status;
@@ -240,12 +241,17 @@ export class QueueBroadcastClient {
240
241
  }
241
242
  }
242
243
  async getPlayback(options = {}) {
243
- const stream = await this.request("/v1/stream", options);
244
+ const { captionTracks, ...requestOptions } = options;
245
+ const stream = await this.request("/v1/stream", requestOptions);
244
246
  const hls = new URL(stream.hls);
245
247
  if (hls.protocol !== "http:" && hls.protocol !== "https:") {
246
248
  throw new QueueBroadcastError("queue server returned a non-http HLS URL", 502);
247
249
  }
248
- return { sessionId: this.endpoint, playbackManifestUrl: hls.toString() };
250
+ return {
251
+ sessionId: this.endpoint,
252
+ playbackManifestUrl: hls.toString(),
253
+ ...(captionTracks === undefined ? {} : { captionTracks: normalizeCaptionTracks(captionTracks) }),
254
+ };
249
255
  }
250
256
  async health(options = {}) {
251
257
  return this.request("/health", options, false);
@@ -0,0 +1,66 @@
1
+ export type ContentQueueState = "queued" | "preparing" | "prepared" | "staged" | "released" | "completed" | "terminal-failed" | "cancelled";
2
+ export interface ContentQueueIdentity {
3
+ channelId: string;
4
+ runEpoch: string;
5
+ contentKey: string;
6
+ ordinal: number;
7
+ }
8
+ export interface ContentCapacity {
9
+ durationMs: number;
10
+ bytes: number;
11
+ contentCount: number;
12
+ visualKey?: string;
13
+ }
14
+ export interface ContentQueueLimits {
15
+ maxInFlight: number;
16
+ maxDurationMs?: number;
17
+ maxBytes?: number;
18
+ maxContentCount?: number;
19
+ }
20
+ export interface ContentQueueObservation {
21
+ identity: ContentQueueIdentity;
22
+ state: ContentQueueState;
23
+ sequence: number;
24
+ error?: unknown;
25
+ }
26
+ export interface ContentQueuePipelineOptions<TRequest, TPrepared> {
27
+ limits: ContentQueueLimits;
28
+ identity(request: TRequest): ContentQueueIdentity;
29
+ estimate(request: TRequest): ContentCapacity;
30
+ measure(prepared: TPrepared, request: TRequest): ContentCapacity;
31
+ prepare(request: TRequest, signal: AbortSignal): Promise<TPrepared>;
32
+ /** Commits completions in enqueue order, even when preparation finishes out of order. */
33
+ commit(observation: ContentQueueObservation, prepared: TPrepared | undefined, request: TRequest): Promise<void>;
34
+ onObservation?: (observation: ContentQueueObservation) => void;
35
+ }
36
+ export interface ContentQueueTicket<TPrepared> {
37
+ identity: ContentQueueIdentity;
38
+ completion: Promise<TPrepared>;
39
+ }
40
+ /** Bounded, ordered preparation for queue-broadcast producers. */
41
+ export declare class ContentQueuePipeline<TRequest, TPrepared> {
42
+ private readonly options;
43
+ private readonly work;
44
+ private readonly pending;
45
+ private nextSequence;
46
+ private nextCommitSequence;
47
+ private active;
48
+ private committing;
49
+ private stopped;
50
+ constructor(options: ContentQueuePipelineOptions<TRequest, TPrepared>);
51
+ enqueue(request: TRequest): ContentQueueTicket<TPrepared>;
52
+ /** Update queue lifecycle after preparation without creating another state owner. */
53
+ acknowledge(identity: ContentQueueIdentity, state: Extract<ContentQueueState, "staged" | "released" | "completed">): void;
54
+ snapshot(): readonly ContentQueueObservation[];
55
+ capacity(): ContentCapacity;
56
+ private capacityExcluding;
57
+ private assertMeasuredCapacity;
58
+ private assertCapacityAgainst;
59
+ stop(reason?: unknown): Promise<void>;
60
+ private assertCapacity;
61
+ private drain;
62
+ private run;
63
+ private commitReady;
64
+ private observe;
65
+ private toObservation;
66
+ }
@@ -0,0 +1,231 @@
1
+ /** Bounded, ordered preparation for queue-broadcast producers. */
2
+ export class ContentQueuePipeline {
3
+ options;
4
+ work = new Map();
5
+ pending = [];
6
+ nextSequence = 1;
7
+ nextCommitSequence = 1;
8
+ active = 0;
9
+ committing = false;
10
+ stopped = false;
11
+ constructor(options) {
12
+ this.options = options;
13
+ const { limits } = options;
14
+ assertPositiveInteger("maxInFlight", limits.maxInFlight);
15
+ assertOptionalLimit("maxDurationMs", limits.maxDurationMs);
16
+ assertOptionalLimit("maxBytes", limits.maxBytes);
17
+ assertOptionalLimit("maxContentCount", limits.maxContentCount);
18
+ }
19
+ enqueue(request) {
20
+ if (this.stopped)
21
+ throw new Error("ContentQueuePipeline is stopped");
22
+ const identity = normalizeIdentity(this.options.identity(request));
23
+ const key = identityKey(identity);
24
+ const existing = this.work.get(key);
25
+ if (existing)
26
+ return { identity: existing.identity, completion: itemCompletion(existing) };
27
+ const estimate = normalizeCapacity(this.options.estimate(request));
28
+ this.assertCapacity(estimate);
29
+ let resolve;
30
+ let reject;
31
+ const completion = new Promise((res, rej) => { resolve = res; reject = rej; });
32
+ const item = {
33
+ sequence: this.nextSequence++, key, identity, request, estimate, state: "queued",
34
+ controller: new AbortController(), resolve, reject,
35
+ };
36
+ Object.defineProperty(item, "completion", { value: completion });
37
+ this.work.set(key, item);
38
+ this.pending.push(item);
39
+ this.observe(item);
40
+ this.drain();
41
+ return { identity, completion };
42
+ }
43
+ /** Update queue lifecycle after preparation without creating another state owner. */
44
+ acknowledge(identity, state) {
45
+ const item = this.work.get(identityKey(normalizeIdentity(identity)));
46
+ if (!item)
47
+ throw new Error("Unknown content queue identity");
48
+ const allowed = item.state === "prepared"
49
+ ? state === "staged"
50
+ : item.state === "staged"
51
+ ? state === "released"
52
+ : item.state === "released" && state === "completed";
53
+ if (!allowed)
54
+ throw new Error(`Invalid content queue transition ${item.state} -> ${state}`);
55
+ item.state = state;
56
+ this.observe(item);
57
+ }
58
+ snapshot() {
59
+ return [...this.work.values()].sort((a, b) => a.sequence - b.sequence).map((item) => this.toObservation(item));
60
+ }
61
+ capacity() {
62
+ return capacityOf([...this.work.values()].filter((item) => !isTerminal(item.state)));
63
+ }
64
+ capacityExcluding(excluded) {
65
+ return capacityOf([...this.work.values()].filter((item) => item !== excluded && !isTerminal(item.state)));
66
+ }
67
+ assertMeasuredCapacity(item, candidate) {
68
+ this.assertCapacityAgainst(this.capacityExcluding(item), candidate, item);
69
+ }
70
+ assertCapacityAgainst(current, candidate, excluded) {
71
+ const sharesVisual = candidate.visualKey && [...this.work.values()].some((item) => {
72
+ if (item === excluded || isTerminal(item.state))
73
+ return false;
74
+ return (item.actual ?? item.estimate).visualKey === candidate.visualKey;
75
+ });
76
+ const bytes = current.bytes + (sharesVisual ? 0 : candidate.bytes);
77
+ const { limits } = this.options;
78
+ if (limits.maxDurationMs !== undefined && current.durationMs + candidate.durationMs > limits.maxDurationMs)
79
+ throw capacityError("duration");
80
+ if (limits.maxBytes !== undefined && bytes > limits.maxBytes)
81
+ throw capacityError("bytes");
82
+ if (limits.maxContentCount !== undefined && current.contentCount + candidate.contentCount > limits.maxContentCount)
83
+ throw capacityError("content count");
84
+ }
85
+ async stop(reason = new Error("ContentQueuePipeline stopped")) {
86
+ if (this.stopped)
87
+ return;
88
+ this.stopped = true;
89
+ for (const item of this.work.values()) {
90
+ if (isTerminal(item.state))
91
+ continue;
92
+ item.controller.abort(reason);
93
+ if (item.state !== "preparing") {
94
+ item.state = "cancelled";
95
+ item.error = reason;
96
+ this.observe(item);
97
+ }
98
+ }
99
+ this.pending.length = 0;
100
+ await this.commitReady();
101
+ await Promise.allSettled([...this.work.values()].map((item) => itemCompletion(item)));
102
+ }
103
+ assertCapacity(candidate) {
104
+ this.assertCapacityAgainst(this.capacity(), candidate);
105
+ }
106
+ drain() {
107
+ while (!this.stopped && this.active < this.options.limits.maxInFlight) {
108
+ const item = this.pending.shift();
109
+ if (!item)
110
+ return;
111
+ this.active += 1;
112
+ item.state = "preparing";
113
+ this.observe(item);
114
+ void this.run(item);
115
+ }
116
+ }
117
+ async run(item) {
118
+ try {
119
+ const prepared = await this.options.prepare(item.request, item.controller.signal);
120
+ if (item.controller.signal.aborted || this.stopped)
121
+ throw item.controller.signal.reason ?? new Error("cancelled");
122
+ const actual = normalizeCapacity(this.options.measure(prepared, item.request));
123
+ this.assertMeasuredCapacity(item, actual);
124
+ item.prepared = prepared;
125
+ item.actual = actual;
126
+ item.state = "prepared";
127
+ }
128
+ catch (error) {
129
+ item.error = error;
130
+ item.state = item.controller.signal.aborted || this.stopped ? "cancelled" : "terminal-failed";
131
+ }
132
+ finally {
133
+ this.active -= 1;
134
+ this.observe(item);
135
+ await this.commitReady();
136
+ this.drain();
137
+ }
138
+ }
139
+ async commitReady() {
140
+ if (this.committing)
141
+ return;
142
+ this.committing = true;
143
+ try {
144
+ while (true) {
145
+ const item = [...this.work.values()].find((candidate) => candidate.sequence === this.nextCommitSequence);
146
+ if (!item || !["prepared", "terminal-failed", "cancelled"].includes(item.state))
147
+ return;
148
+ const observation = this.toObservation(item);
149
+ try {
150
+ await this.options.commit(observation, item.prepared, item.request);
151
+ if (item.state === "prepared")
152
+ item.resolve(item.prepared);
153
+ else
154
+ item.reject(item.error);
155
+ this.nextCommitSequence += 1;
156
+ }
157
+ catch (error) {
158
+ item.error = error;
159
+ item.state = "terminal-failed";
160
+ this.observe(item);
161
+ item.reject(error);
162
+ this.nextCommitSequence += 1;
163
+ }
164
+ }
165
+ }
166
+ finally {
167
+ this.committing = false;
168
+ const next = [...this.work.values()].find((candidate) => candidate.sequence === this.nextCommitSequence);
169
+ if (next && ["prepared", "terminal-failed", "cancelled"].includes(next.state))
170
+ void this.commitReady();
171
+ }
172
+ }
173
+ observe(item) {
174
+ this.options.onObservation?.(this.toObservation(item));
175
+ }
176
+ toObservation(item) {
177
+ return { identity: item.identity, state: item.state, sequence: item.sequence, ...(item.error === undefined ? {} : { error: item.error }) };
178
+ }
179
+ }
180
+ function capacityOf(current) {
181
+ const visualKeys = new Set();
182
+ return current.reduce((total, item) => {
183
+ const measure = item.actual ?? item.estimate;
184
+ const countVisual = !measure.visualKey || !visualKeys.has(measure.visualKey);
185
+ if (measure.visualKey)
186
+ visualKeys.add(measure.visualKey);
187
+ total.durationMs += measure.durationMs;
188
+ total.bytes += countVisual ? measure.bytes : 0;
189
+ total.contentCount += measure.contentCount;
190
+ return total;
191
+ }, { durationMs: 0, bytes: 0, contentCount: 0 });
192
+ }
193
+ function itemCompletion(item) {
194
+ return item.completion;
195
+ }
196
+ function normalizeIdentity(identity) {
197
+ const result = { ...identity, channelId: identity.channelId.trim(), runEpoch: identity.runEpoch.trim(), contentKey: identity.contentKey.trim() };
198
+ if (!result.channelId || !result.runEpoch || !result.contentKey)
199
+ throw new TypeError("content identity fields are required");
200
+ if (!Number.isInteger(result.ordinal) || result.ordinal < 0)
201
+ throw new TypeError("ordinal must be a non-negative integer");
202
+ return result;
203
+ }
204
+ function identityKey(identity) {
205
+ return JSON.stringify([identity.channelId, identity.runEpoch, identity.contentKey, identity.ordinal]);
206
+ }
207
+ function normalizeCapacity(value) {
208
+ for (const [name, amount] of Object.entries({ durationMs: value.durationMs, bytes: value.bytes, contentCount: value.contentCount })) {
209
+ if (!Number.isFinite(amount) || amount < 0)
210
+ throw new TypeError(`${name} must be non-negative`);
211
+ }
212
+ if (!Number.isInteger(value.contentCount))
213
+ throw new TypeError("contentCount must be an integer");
214
+ return { ...value, visualKey: value.visualKey?.trim() || undefined };
215
+ }
216
+ function isTerminal(state) {
217
+ return state === "completed" || state === "terminal-failed" || state === "cancelled";
218
+ }
219
+ function capacityError(kind) {
220
+ const error = new Error(`Content queue ${kind} capacity exceeded`);
221
+ error.name = "ContentQueueCapacityError";
222
+ return error;
223
+ }
224
+ function assertPositiveInteger(name, value) {
225
+ if (!Number.isInteger(value) || value < 1)
226
+ throw new TypeError(`${name} must be a positive integer`);
227
+ }
228
+ function assertOptionalLimit(name, value) {
229
+ if (value !== undefined && (!Number.isFinite(value) || value < 0))
230
+ throw new TypeError(`${name} must be non-negative`);
231
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./client.js";
2
+ export * from "./content-pipeline.js";
2
3
  export * from "./generated/api.js";
3
4
  export * from "./streaming/index.js";
4
5
  export * from "./monitoring/metrics.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./client.js";
2
+ export * from "./content-pipeline.js";
2
3
  // Low-level OpenAPI client. Prefer QueueBroadcastClient for application code.
3
4
  export * from "./generated/api.js";
4
5
  // Streaming infrastructure
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@portalshq/capability-queue-broadcast",
3
- "version": "0.1.5",
3
+ "version": "0.1.9",
4
4
  "repository": {
5
5
  "type": "git",
6
- "url": "git+https://github.com/portalshq/portals-cloud.git"
6
+ "url": "git+https://github.com/portalshq/portals-cloud.git",
7
+ "directory": "packages/queue-broadcast"
7
8
  },
8
9
  "description": "Server-only client for isolated Queue Broadcast Server instances.",
9
10
  "type": "module",
@@ -14,7 +15,6 @@
14
15
  ],
15
16
  "scripts": {
16
17
  "build": "rm -rf dist && tsc -p tsconfig.json",
17
- "publish": "node ../../scripts/publish-workspace.mjs",
18
18
  "typecheck": "tsc -p tsconfig.json --noEmit",
19
19
  "test": "vitest run",
20
20
  "test:coverage": "vitest run --coverage",
@@ -22,13 +22,17 @@
22
22
  "generate:check": "npm run generate && npm run typecheck"
23
23
  },
24
24
  "dependencies": {
25
- "@portalshq/capability-realtime-fanout": "^0.1.4",
26
- "@portalshq/capability-video-delivery": "^0.1.4"
25
+ "@portalshq/capability-realtime-fanout": "^0.1.8",
26
+ "@portalshq/capability-video-delivery": "^0.1.8"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^20.0.0",
30
30
  "@vitest/coverage-v8": "^3.2.7",
31
31
  "typescript": "^5.5.0",
32
32
  "vitest": "^3.2.7"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "registry": "https://registry.npmjs.org/"
33
37
  }
34
38
  }