@antcdn/live-signaling 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AntCDN
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # @antcdn/live-signaling
2
+
3
+ Interactivity for AntCDN live streams and their recordings: viewer presence, host-driven questions, and voting. It is independent of any video player.
4
+
5
+ Works with player-ant, hls.js, Shaka Player, a native app shell, or a page with no video at all. It opens a WebSocket and emits events; it never touches a video element.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ pnpm add @antcdn/live-signaling
11
+ ```
12
+
13
+ ## Live
14
+
15
+ ```ts
16
+ import { connectLiveRoom } from '@antcdn/live-signaling';
17
+
18
+ const room = connectLiveRoom({
19
+ streamId: 'ls_2VQDR4DO8RTVADPQ21X95U',
20
+ // Optional. Prefer the id from your playback session; otherwise one is
21
+ // generated and persisted in localStorage.
22
+ sessionId: playbackSession?.sessionId,
23
+ });
24
+
25
+ room.on('count', ({ viewerCount }) => renderViewerCount(viewerCount));
26
+ room.on('question', (q) => showQuestion(q)); // fires at reveal time
27
+ room.on('tally', (t) => renderResults(t.counts));
28
+ room.on('closed', (t) => renderFinal(t.counts));
29
+ room.on('error', ({ code }) => { if (code === 'already_voted') markAnswered(); });
30
+
31
+ // When the viewer answers
32
+ room.vote(questionId, optionIndex);
33
+ ```
34
+
35
+ ### Reveal timing, the part that matters
36
+
37
+ A question asked *inside the video* must appear when the viewer **hears it asked**, not when the socket delivered it. A viewer eight seconds behind the live edge would otherwise get a prompt about something that has not happened yet.
38
+
39
+ So questions marked `cued` are **held** until you report the matching in-band cue:
40
+
41
+ ```ts
42
+ // Shaka Player
43
+ player.addEventListener('metadata', (e) => {
44
+ const questionId = readQuestionId(e); // from the ID3/emsg payload
45
+ if (questionId) room.cue(questionId);
46
+ });
47
+ ```
48
+
49
+ Order does not matter. A cue arriving before its question is remembered, and the question reveals the moment it lands.
50
+
51
+ Hosts and moderators are not on delay and need to see a question the instant it opens:
52
+
53
+ ```ts
54
+ connectLiveRoom({ streamId, revealImmediately: true });
55
+ ```
56
+
57
+ Never set that for ordinary viewers; it reintroduces exactly the early-reveal problem cues exist to prevent.
58
+
59
+ ## Recorded
60
+
61
+ The same events, driven by playback position instead of a socket:
62
+
63
+ ```ts
64
+ import { replayLiveRoom } from '@antcdn/live-signaling';
65
+
66
+ const replay = replayLiveRoom({ questions }); // from the recording's question timeline
67
+
68
+ replay.on('question', (q) => showQuestion(q));
69
+ replay.on('tally', (t) => renderResults(t.counts)); // what the live audience answered
70
+
71
+ video.addEventListener('timeupdate', () => replay.update(video.currentTime));
72
+ ```
73
+
74
+ Seeking behaves the way a viewer expects: scrubbing backwards re-arms questions so a rewatch works, and scrubbing far forward does **not** fire a backlog. Only a question within `revealWindowSeconds` (default 30) of where you landed is shown.
75
+
76
+ Because the event shape is identical, one set of handlers serves both live and recorded playback.
77
+
78
+ ## Identity and voting
79
+
80
+ Votes are deduplicated by session id, one vote per session per question. A second vote returns `error` with code `already_voted`.
81
+
82
+ Supplying your own `sessionId`, ideally the one AntCDN's secure-playback handshake issues, makes that meaningful. The generated fallback is deliberately weak: clearing site data earns another vote. That matches the bar the feature sets; do not build anything on it that needs a verified identity.
83
+
84
+ ## Protocol
85
+
86
+ The SDK is a convenience over a plain JSON-over-WebSocket protocol. Implement it directly on any platform.
87
+
88
+ ```
89
+ GET wss://worker.antcdn.net/v1/{streamId}/live-room?sessionId={sessionId}
90
+ GET https://worker.antcdn.net/v1/{streamId}/live-room/count
91
+ ```
92
+
93
+ **Server → client**
94
+
95
+ | `type` | Payload |
96
+ |---|---|
97
+ | `welcome` | `streamId`, `viewerCount`, `activeQuestion?`, `hasVoted?`, `tally?` |
98
+ | `count` | `viewerCount` |
99
+ | `question` | `question: { id, prompt, options, cued? }` |
100
+ | `tally` | `tally: { questionId, counts[], total }` |
101
+ | `closed` | `tally`, final |
102
+ | `error` | `code`, `message` |
103
+
104
+ **Client → server**
105
+
106
+ | `type` | Payload |
107
+ |---|---|
108
+ | `vote` | `questionId`, `optionIndex` |
109
+
110
+ Every message carries `v`, the protocol version. The client warns on a mismatch rather than failing. Unknown fields are ignored, so additive server changes are safe.
111
+
112
+ `welcome` is sent on every connect, including reconnects, and carries enough state to resume: the open question, whether this session already answered it, and the tally so far. Reconnection is automatic with jittered backoff.
@@ -0,0 +1,119 @@
1
+ import { type LiveQuestion, type LiveTally, type ServerErrorCode } from './protocol.js';
2
+ /**
3
+ * The live-room client.
4
+ *
5
+ * Player-agnostic by design: it opens a WebSocket and emits events. It never touches a
6
+ * video element, so it works with player-ant, hls.js, Shaka, a native app shell, or a
7
+ * page with no video at all (a host dashboard, say).
8
+ *
9
+ * Its one piece of real logic is **reveal timing**. A question tied to an in-band cue is
10
+ * held on arrival and released when the player reports the matching cue, so viewers at
11
+ * different buffer depths all see it at the same moment in the video. Everything else
12
+ * is transport.
13
+ */
14
+ export interface LiveRoomEvents {
15
+ /** Connected, with whatever state the room already had. */
16
+ welcome: {
17
+ streamId: string;
18
+ viewerCount: number;
19
+ hasVoted: boolean;
20
+ };
21
+ /** Current viewer count. Coalesced server-side, so this is not chatty. */
22
+ count: {
23
+ viewerCount: number;
24
+ };
25
+ /** A question, at reveal time — held ones fire when their cue arrives, not on receipt. */
26
+ question: LiveQuestion;
27
+ /** Running results. */
28
+ tally: LiveTally;
29
+ /** The host closed the question; these are the final numbers. */
30
+ closed: LiveTally;
31
+ /** The server rejected something. `already_voted` is the common, benign one. */
32
+ error: {
33
+ code: ServerErrorCode;
34
+ message: string;
35
+ };
36
+ /** Transport state, so a UI can show a reconnecting indicator. */
37
+ status: {
38
+ connected: boolean;
39
+ reconnecting: boolean;
40
+ };
41
+ }
42
+ type Listener<K extends keyof LiveRoomEvents> = (payload: LiveRoomEvents[K]) => void;
43
+ export interface LiveRoomOptions {
44
+ streamId: string;
45
+ /** Defaults to AntCDN's worker. Override for self-hosted or local development. */
46
+ baseUrl?: string;
47
+ /** Prefer the id from your playback session; one is generated and persisted if omitted. */
48
+ sessionId?: string;
49
+ /**
50
+ * Reveal cued questions immediately instead of waiting for `cue()`.
51
+ *
52
+ * For hosts and moderators, who are not watching on delay and need to see the
53
+ * question the moment it opens. Never set this for ordinary viewers — it
54
+ * reintroduces exactly the early-reveal problem cues exist to prevent.
55
+ */
56
+ revealImmediately?: boolean;
57
+ /** Injectable for tests and non-browser runtimes. */
58
+ webSocketFactory?: (url: string) => WebSocket;
59
+ }
60
+ export declare class LiveRoom {
61
+ private readonly options;
62
+ private readonly sessionId;
63
+ private socket;
64
+ private listeners;
65
+ /** Cued questions waiting for their cue, keyed by question id. */
66
+ private pending;
67
+ /** Cues that arrived before their question did — a race worth surviving. */
68
+ private earlyCues;
69
+ /** Revealed already, so a duplicate cue cannot fire the event twice. */
70
+ private revealed;
71
+ private closedByCaller;
72
+ private reconnectAttempts;
73
+ private reconnectTimer;
74
+ constructor(options: LiveRoomOptions);
75
+ on<K extends keyof LiveRoomEvents>(event: K, listener: Listener<K>): () => void;
76
+ connect(): void;
77
+ /**
78
+ * Report an in-band cue from the video.
79
+ *
80
+ * Call this from whatever surfaces timed metadata — Shaka's `metadata` event, hls.js
81
+ * fragment metadata, a native player's callback.
82
+ *
83
+ * Pass the question the cue carried, and it is revealed there and then: no round
84
+ * trip at the one moment that has to be instant, and it works even if this room's
85
+ * connection is down. AntCDN's own pipeline always puts the whole question in the
86
+ * media, for exactly that reason.
87
+ *
88
+ * Passing only an id is still supported, for encoders that carry no more than that.
89
+ * Then the question has to arrive over the socket before it can be shown, and the
90
+ * cue is remembered until it does.
91
+ */
92
+ cue(cue: LiveQuestion | string): void;
93
+ /**
94
+ * Answer a question by choosing an option, writing a reply, or both.
95
+ *
96
+ * Pass FREE_TEXT_ONLY as the option index to answer in writing without choosing.
97
+ */
98
+ vote(questionId: string, optionIndex: number, text?: string): void;
99
+ close(): void;
100
+ private url;
101
+ private open;
102
+ /**
103
+ * Exponential backoff with jitter.
104
+ *
105
+ * Jitter matters more than usual here: when a stream ends or a Worker redeploys,
106
+ * every viewer disconnects at once, and un-jittered backoff would have them all
107
+ * retry in lockstep.
108
+ */
109
+ private scheduleReconnect;
110
+ private send;
111
+ private receive;
112
+ /** Reveal now, or hold until the cue says the video has reached the question. */
113
+ private acceptQuestion;
114
+ private reveal;
115
+ private emit;
116
+ }
117
+ /** Convenience: construct and connect in one call. */
118
+ export declare function connectLiveRoom(options: LiveRoomOptions): LiveRoom;
119
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,230 @@
1
+ import { PROTOCOL_VERSION, } from './protocol.js';
2
+ import { resolveSessionId } from './session.js';
3
+ const RECONNECT_BASE_MS = 500;
4
+ const RECONNECT_MAX_MS = 15000;
5
+ export class LiveRoom {
6
+ options;
7
+ sessionId;
8
+ socket = null;
9
+ listeners = new Map();
10
+ /** Cued questions waiting for their cue, keyed by question id. */
11
+ pending = new Map();
12
+ /** Cues that arrived before their question did — a race worth surviving. */
13
+ earlyCues = new Set();
14
+ /** Revealed already, so a duplicate cue cannot fire the event twice. */
15
+ revealed = new Set();
16
+ closedByCaller = false;
17
+ reconnectAttempts = 0;
18
+ reconnectTimer = null;
19
+ constructor(options) {
20
+ this.options = options;
21
+ this.sessionId = resolveSessionId(options.sessionId);
22
+ }
23
+ // ─── public surface ───────────────────────────────────────────────────────
24
+ on(event, listener) {
25
+ const set = this.listeners.get(event) ?? new Set();
26
+ set.add(listener);
27
+ this.listeners.set(event, set);
28
+ return () => set.delete(listener);
29
+ }
30
+ connect() {
31
+ this.closedByCaller = false;
32
+ this.open();
33
+ }
34
+ /**
35
+ * Report an in-band cue from the video.
36
+ *
37
+ * Call this from whatever surfaces timed metadata — Shaka's `metadata` event, hls.js
38
+ * fragment metadata, a native player's callback.
39
+ *
40
+ * Pass the question the cue carried, and it is revealed there and then: no round
41
+ * trip at the one moment that has to be instant, and it works even if this room's
42
+ * connection is down. AntCDN's own pipeline always puts the whole question in the
43
+ * media, for exactly that reason.
44
+ *
45
+ * Passing only an id is still supported, for encoders that carry no more than that.
46
+ * Then the question has to arrive over the socket before it can be shown, and the
47
+ * cue is remembered until it does.
48
+ */
49
+ cue(cue) {
50
+ if (typeof cue === 'string') {
51
+ if (this.revealed.has(cue))
52
+ return;
53
+ const question = this.pending.get(cue);
54
+ if (question) {
55
+ this.pending.delete(cue);
56
+ this.reveal(question);
57
+ return;
58
+ }
59
+ this.earlyCues.add(cue);
60
+ return;
61
+ }
62
+ if (this.revealed.has(cue.id))
63
+ return;
64
+ // Nothing to wait for — the cue brought the question with it.
65
+ this.pending.delete(cue.id);
66
+ this.earlyCues.delete(cue.id);
67
+ this.reveal(cue);
68
+ }
69
+ /**
70
+ * Answer a question by choosing an option, writing a reply, or both.
71
+ *
72
+ * Pass FREE_TEXT_ONLY as the option index to answer in writing without choosing.
73
+ */
74
+ vote(questionId, optionIndex, text) {
75
+ this.send({ v: PROTOCOL_VERSION, type: 'vote', questionId, optionIndex, ...(text ? { text } : {}) });
76
+ }
77
+ close() {
78
+ this.closedByCaller = true;
79
+ if (this.reconnectTimer)
80
+ clearTimeout(this.reconnectTimer);
81
+ this.reconnectTimer = null;
82
+ this.socket?.close();
83
+ this.socket = null;
84
+ }
85
+ // ─── transport ────────────────────────────────────────────────────────────
86
+ url() {
87
+ const base = this.options.baseUrl ?? 'https://worker.antcdn.net';
88
+ const u = new URL(`${base.replace(/\/$/, '')}/v1/${this.options.streamId}/live-room`);
89
+ u.protocol = u.protocol === 'http:' ? 'ws:' : 'wss:';
90
+ u.searchParams.set('sessionId', this.sessionId);
91
+ return u.toString();
92
+ }
93
+ open() {
94
+ const factory = this.options.webSocketFactory ?? ((url) => new WebSocket(url));
95
+ let socket;
96
+ try {
97
+ socket = factory(this.url());
98
+ }
99
+ catch {
100
+ this.scheduleReconnect();
101
+ return;
102
+ }
103
+ this.socket = socket;
104
+ socket.onopen = () => {
105
+ this.reconnectAttempts = 0;
106
+ this.emit('status', { connected: true, reconnecting: false });
107
+ };
108
+ socket.onmessage = (event) => this.receive(event.data);
109
+ socket.onclose = () => {
110
+ this.emit('status', { connected: false, reconnecting: !this.closedByCaller });
111
+ this.scheduleReconnect();
112
+ };
113
+ socket.onerror = () => {
114
+ // onclose always follows, and that is where reconnection is handled.
115
+ };
116
+ }
117
+ /**
118
+ * Exponential backoff with jitter.
119
+ *
120
+ * Jitter matters more than usual here: when a stream ends or a Worker redeploys,
121
+ * every viewer disconnects at once, and un-jittered backoff would have them all
122
+ * retry in lockstep.
123
+ */
124
+ scheduleReconnect() {
125
+ if (this.closedByCaller || this.reconnectTimer)
126
+ return;
127
+ const backoff = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, RECONNECT_MAX_MS);
128
+ const delay = backoff * (0.5 + Math.random() / 2);
129
+ this.reconnectAttempts += 1;
130
+ this.reconnectTimer = setTimeout(() => {
131
+ this.reconnectTimer = null;
132
+ if (!this.closedByCaller)
133
+ this.open();
134
+ }, delay);
135
+ }
136
+ send(message) {
137
+ if (this.socket?.readyState !== 1 /* OPEN */)
138
+ return;
139
+ try {
140
+ this.socket.send(JSON.stringify(message));
141
+ }
142
+ catch {
143
+ // Dropped in flight; the reconnect path will re-establish state.
144
+ }
145
+ }
146
+ // ─── message handling ─────────────────────────────────────────────────────
147
+ receive(raw) {
148
+ let msg;
149
+ try {
150
+ msg = JSON.parse(String(raw));
151
+ }
152
+ catch {
153
+ return;
154
+ }
155
+ if (typeof msg.v === 'number' && msg.v !== PROTOCOL_VERSION) {
156
+ // Forward-compatible: unknown fields are ignored rather than fatal, but a
157
+ // mismatch is worth surfacing once rather than debugging blind.
158
+ console.warn(`[live-signaling] server protocol v${msg.v}, client v${PROTOCOL_VERSION} — update @antcdn/live-signaling`);
159
+ }
160
+ switch (msg.type) {
161
+ case 'welcome':
162
+ this.emit('welcome', {
163
+ streamId: msg.streamId,
164
+ viewerCount: msg.viewerCount,
165
+ hasVoted: Boolean(msg.hasVoted),
166
+ });
167
+ this.emit('count', { viewerCount: msg.viewerCount });
168
+ // A question already open when we joined. Same reveal rules apply, so a
169
+ // late joiner behind the live edge still sees it at the right moment.
170
+ if (msg.activeQuestion)
171
+ this.acceptQuestion(msg.activeQuestion);
172
+ if (msg.tally)
173
+ this.emit('tally', msg.tally);
174
+ return;
175
+ case 'count':
176
+ this.emit('count', { viewerCount: msg.viewerCount });
177
+ return;
178
+ case 'question':
179
+ this.acceptQuestion(msg.question);
180
+ return;
181
+ case 'tally':
182
+ this.emit('tally', msg.tally);
183
+ return;
184
+ case 'closed':
185
+ this.pending.delete(msg.tally.questionId);
186
+ this.emit('closed', msg.tally);
187
+ return;
188
+ case 'error':
189
+ this.emit('error', { code: msg.code, message: msg.message });
190
+ return;
191
+ }
192
+ }
193
+ /** Reveal now, or hold until the cue says the video has reached the question. */
194
+ acceptQuestion(question) {
195
+ if (this.revealed.has(question.id))
196
+ return;
197
+ const waitForCue = question.cued && !this.options.revealImmediately;
198
+ if (!waitForCue) {
199
+ this.reveal(question);
200
+ return;
201
+ }
202
+ if (this.earlyCues.has(question.id)) {
203
+ this.earlyCues.delete(question.id);
204
+ this.reveal(question);
205
+ return;
206
+ }
207
+ this.pending.set(question.id, question);
208
+ }
209
+ reveal(question) {
210
+ this.revealed.add(question.id);
211
+ this.emit('question', question);
212
+ }
213
+ emit(event, payload) {
214
+ for (const listener of this.listeners.get(event) ?? []) {
215
+ try {
216
+ listener(payload);
217
+ }
218
+ catch (err) {
219
+ // One bad listener must not stop the others, or break the socket loop.
220
+ console.error(`[live-signaling] listener for "${event}" threw`, err);
221
+ }
222
+ }
223
+ }
224
+ }
225
+ /** Convenience: construct and connect in one call. */
226
+ export function connectLiveRoom(options) {
227
+ const room = new LiveRoom(options);
228
+ room.connect();
229
+ return room;
230
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @antcdn/live-signaling
3
+ *
4
+ * Interactivity for AntCDN live streams and their recordings: viewer presence,
5
+ * host-driven questions, and voting. It is independent of any video player.
6
+ *
7
+ * Two sources, one set of events:
8
+ *
9
+ * connectLiveRoom() live, over a WebSocket
10
+ * replayLiveRoom() recorded, from a timeline of offsets
11
+ *
12
+ * Integrate once, and a customer's own UI works for both.
13
+ */
14
+ export { LiveRoom, connectLiveRoom } from './client.js';
15
+ export type { LiveRoomOptions, LiveRoomEvents } from './client.js';
16
+ export { LiveRoomReplay, replayLiveRoom } from './replay.js';
17
+ export type { ReplayOptions, ReplayEvents } from './replay.js';
18
+ export { resolveSessionId } from './session.js';
19
+ export { PROTOCOL_VERSION, FREE_TEXT_ONLY, MAX_FREE_TEXT_LENGTH } from './protocol.js';
20
+ export type { LiveQuestion, LiveTally, RecordedQuestion, ServerMessage, ClientMessage, ServerErrorCode, } from './protocol.js';
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @antcdn/live-signaling
3
+ *
4
+ * Interactivity for AntCDN live streams and their recordings: viewer presence,
5
+ * host-driven questions, and voting. It is independent of any video player.
6
+ *
7
+ * Two sources, one set of events:
8
+ *
9
+ * connectLiveRoom() live, over a WebSocket
10
+ * replayLiveRoom() recorded, from a timeline of offsets
11
+ *
12
+ * Integrate once, and a customer's own UI works for both.
13
+ */
14
+ export { LiveRoom, connectLiveRoom } from './client.js';
15
+ export { LiveRoomReplay, replayLiveRoom } from './replay.js';
16
+ export { resolveSessionId } from './session.js';
17
+ export { PROTOCOL_VERSION, FREE_TEXT_ONLY, MAX_FREE_TEXT_LENGTH } from './protocol.js';
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The live-room wire protocol.
3
+ *
4
+ * A deliberate copy of the Worker's `live_room_protocol.ts`, not an import: this package
5
+ * ships and versions independently of the Worker, and a customer implementing the raw
6
+ * protocol in another language has only this file as the contract.
7
+ *
8
+ * The two copies must agree. `PROTOCOL_VERSION` is what makes a disagreement visible
9
+ * rather than silent — the client warns when a server message carries a version it was
10
+ * not built against.
11
+ */
12
+ export declare const PROTOCOL_VERSION = 1;
13
+ export interface LiveQuestion {
14
+ id: string;
15
+ prompt: string;
16
+ options: string[];
17
+ /**
18
+ * True when the question is tied to an in-band cue in the video.
19
+ *
20
+ * The client holds these until the player reports the matching cue, so the question
21
+ * appears when the streamer actually asked it rather than when the socket delivered
22
+ * it. Without this, a viewer eight seconds behind the live edge would see a question
23
+ * eight seconds before hearing it — a prompt about something that has not happened.
24
+ */
25
+ cued?: boolean;
26
+ /**
27
+ * When set, viewers may answer in writing as well as, or instead of, picking an
28
+ * option. Written answers are counted but never folded into the option counts —
29
+ * they are not comparable, and merging them would misreport both.
30
+ */
31
+ allowFreeText?: boolean;
32
+ }
33
+ export interface LiveTally {
34
+ questionId: string;
35
+ /** Index-aligned with the question's options. */
36
+ counts: number[];
37
+ total: number;
38
+ /**
39
+ * Written answers received, when the question allows them. Deliberately separate
40
+ * from `counts`: a written answer is not a choice between the options, so adding it
41
+ * to any of them — or to `total` — would misstate the result.
42
+ */
43
+ textCount?: number;
44
+ }
45
+ export type ServerErrorCode = 'bad_message' | 'no_active_question' | 'already_voted' | 'unknown_option' | 'text_not_allowed' | 'text_too_long';
46
+ export interface WelcomeMessage {
47
+ v: number;
48
+ type: 'welcome';
49
+ streamId: string;
50
+ viewerCount: number;
51
+ activeQuestion?: LiveQuestion;
52
+ hasVoted?: boolean;
53
+ tally?: LiveTally;
54
+ }
55
+ export interface CountMessage {
56
+ v: number;
57
+ type: 'count';
58
+ viewerCount: number;
59
+ }
60
+ export interface QuestionMessage {
61
+ v: number;
62
+ type: 'question';
63
+ question: LiveQuestion;
64
+ }
65
+ export interface TallyMessage {
66
+ v: number;
67
+ type: 'tally';
68
+ tally: LiveTally;
69
+ }
70
+ export interface ClosedMessage {
71
+ v: number;
72
+ type: 'closed';
73
+ tally: LiveTally;
74
+ }
75
+ export interface ErrorMessage {
76
+ v: number;
77
+ type: 'error';
78
+ code: ServerErrorCode;
79
+ message: string;
80
+ }
81
+ export type ServerMessage = WelcomeMessage | CountMessage | QuestionMessage | TallyMessage | ClosedMessage | ErrorMessage;
82
+ export interface VoteMessage {
83
+ v: number;
84
+ type: 'vote';
85
+ questionId: string;
86
+ /** The chosen option, or FREE_TEXT_ONLY when the viewer is answering in writing. */
87
+ optionIndex: number;
88
+ /** A written answer. Rejected when the question does not allow one. */
89
+ text?: string;
90
+ }
91
+ /**
92
+ * `optionIndex` for a written answer with no option chosen.
93
+ *
94
+ * A sentinel rather than an omitted field so that every vote has an option index and
95
+ * the server never has to distinguish "absent" from "index 0" — a distinction JSON and
96
+ * JavaScript both make easy to get wrong.
97
+ */
98
+ export declare const FREE_TEXT_ONLY = -1;
99
+ /**
100
+ * Longest written answer accepted, in characters.
101
+ *
102
+ * Enforced server-side. This is audience input arriving at broadcast rates, so the
103
+ * bound is on the server, where it protects storage, and not only in the UI.
104
+ */
105
+ export declare const MAX_FREE_TEXT_LENGTH = 280;
106
+ export type ClientMessage = VoteMessage;
107
+ /** One question as recorded, for replaying interactivity over a VOD asset. */
108
+ export interface RecordedQuestion {
109
+ id: string;
110
+ prompt: string;
111
+ options: string[];
112
+ /** Seconds from the start of the stream — the same instant the live cue fired. */
113
+ offsetSeconds: number;
114
+ /**
115
+ * Whether the question took written answers.
116
+ *
117
+ * Carried into the recording because a replay shows the question as it was asked:
118
+ * without this a viewer would be offered a narrower question than the live audience
119
+ * got, and the text they wanted to write would have nowhere to go.
120
+ */
121
+ allowFreeText?: boolean;
122
+ /** Final live tally, when the caller wants to show what the live audience answered. */
123
+ tally?: LiveTally;
124
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The live-room wire protocol.
3
+ *
4
+ * A deliberate copy of the Worker's `live_room_protocol.ts`, not an import: this package
5
+ * ships and versions independently of the Worker, and a customer implementing the raw
6
+ * protocol in another language has only this file as the contract.
7
+ *
8
+ * The two copies must agree. `PROTOCOL_VERSION` is what makes a disagreement visible
9
+ * rather than silent — the client warns when a server message carries a version it was
10
+ * not built against.
11
+ */
12
+ export const PROTOCOL_VERSION = 1;
13
+ /**
14
+ * `optionIndex` for a written answer with no option chosen.
15
+ *
16
+ * A sentinel rather than an omitted field so that every vote has an option index and
17
+ * the server never has to distinguish "absent" from "index 0" — a distinction JSON and
18
+ * JavaScript both make easy to get wrong.
19
+ */
20
+ export const FREE_TEXT_ONLY = -1;
21
+ /**
22
+ * Longest written answer accepted, in characters.
23
+ *
24
+ * Enforced server-side. This is audience input arriving at broadcast rates, so the
25
+ * bound is on the server, where it protects storage, and not only in the UI.
26
+ */
27
+ export const MAX_FREE_TEXT_LENGTH = 280;
@@ -0,0 +1,79 @@
1
+ import { type LiveQuestion, type LiveTally, type RecordedQuestion } from './protocol.js';
2
+ /**
3
+ * Interactivity over a recorded stream.
4
+ *
5
+ * A live stream that was recorded keeps its questions as a timeline of offsets from the
6
+ * start of the broadcast. Replaying them against `currentTime` gives a VOD viewer the
7
+ * same experience the live audience had, in the same order, at the same points — the
8
+ * mechanism YouTube cards and Twitch's VOD chat replay use.
9
+ *
10
+ * Deliberately the same event names as the live client, so an integrator writes one set
11
+ * of handlers and switches source rather than writing the feature twice.
12
+ *
13
+ * Seeking is handled properly: jumping backwards re-arms questions so they fire again,
14
+ * and jumping forwards past several does not fire a burst of stale prompts — only the
15
+ * most recent one is presented, because that is what a viewer scrubbing into the middle
16
+ * of a talk would expect to be looking at.
17
+ */
18
+ export interface ReplayEvents {
19
+ question: LiveQuestion;
20
+ tally: LiveTally;
21
+ }
22
+ export interface ReplayOptions {
23
+ questions: RecordedQuestion[];
24
+ /**
25
+ * The stream this recording came from. Required to submit answers; omit it and the
26
+ * replay is read-only.
27
+ */
28
+ streamId?: string;
29
+ /** Defaults to AntCDN's worker. Override for self-hosted or local development. */
30
+ baseUrl?: string;
31
+ /** Prefer the id from your playback session; one is generated and persisted if omitted. */
32
+ sessionId?: string;
33
+ /** Injectable for tests and non-browser runtimes. */
34
+ fetchImpl?: typeof fetch;
35
+ /**
36
+ * How far past a question's offset it may still be revealed, in seconds.
37
+ *
38
+ * Without a bound, seeking to the end of a two-hour recording would reveal the
39
+ * question from minute three. With one, a scrub lands you in the state a viewer at
40
+ * that point would actually be in.
41
+ */
42
+ revealWindowSeconds?: number;
43
+ }
44
+ export declare class LiveRoomReplay {
45
+ private readonly questions;
46
+ private readonly revealWindow;
47
+ private listeners;
48
+ private revealed;
49
+ private lastTime;
50
+ private readonly options;
51
+ private readonly sessionId;
52
+ /** Answered in this session, so the UI need not wait on a round trip to know. */
53
+ private answered;
54
+ constructor(options: ReplayOptions);
55
+ /**
56
+ * Answer a question while watching the recording.
57
+ *
58
+ * Recorded against the recording, never against the broadcast. What the live
59
+ * audience said is a record of a moment that has passed; a recording collects
60
+ * answers for as long as anyone watches it, and merging the two would let the live
61
+ * result drift for years after the stream ended.
62
+ *
63
+ * Resolves true when the answer was stored, false when this viewer had already
64
+ * answered — which is not an error, and is what a retried submission should report.
65
+ */
66
+ answer(questionId: string, optionIndex: number, text?: string): Promise<boolean>;
67
+ /** Whether this viewer has answered the question during this session. */
68
+ hasAnswered(questionId: string): boolean;
69
+ on<K extends keyof ReplayEvents>(event: K, listener: (payload: ReplayEvents[K]) => void): () => void;
70
+ /**
71
+ * Drive from playback position. Call on `timeupdate` — a few times a second is
72
+ * plenty, since question offsets are seconds apart at best.
73
+ */
74
+ update(currentTimeSeconds: number): void;
75
+ /** Questions after this point become eligible again, so a rewatch behaves like a first watch. */
76
+ private rearmAfter;
77
+ private emit;
78
+ }
79
+ export declare function replayLiveRoom(options: ReplayOptions): LiveRoomReplay;
package/dist/replay.js ADDED
@@ -0,0 +1,122 @@
1
+ import { FREE_TEXT_ONLY, MAX_FREE_TEXT_LENGTH } from './protocol.js';
2
+ import { resolveSessionId } from './session.js';
3
+ const DEFAULT_BASE_URL = 'https://worker.antcdn.net';
4
+ const DEFAULT_REVEAL_WINDOW_SECONDS = 30;
5
+ export class LiveRoomReplay {
6
+ questions;
7
+ revealWindow;
8
+ listeners = new Map();
9
+ revealed = new Set();
10
+ lastTime = 0;
11
+ options;
12
+ sessionId;
13
+ /** Answered in this session, so the UI need not wait on a round trip to know. */
14
+ answered = new Set();
15
+ constructor(options) {
16
+ // Sorted once so `update` is a scan from a known order rather than a search.
17
+ this.questions = [...options.questions].sort((a, b) => a.offsetSeconds - b.offsetSeconds);
18
+ this.revealWindow = options.revealWindowSeconds ?? DEFAULT_REVEAL_WINDOW_SECONDS;
19
+ this.options = options;
20
+ this.sessionId = resolveSessionId(options.sessionId);
21
+ }
22
+ /**
23
+ * Answer a question while watching the recording.
24
+ *
25
+ * Recorded against the recording, never against the broadcast. What the live
26
+ * audience said is a record of a moment that has passed; a recording collects
27
+ * answers for as long as anyone watches it, and merging the two would let the live
28
+ * result drift for years after the stream ended.
29
+ *
30
+ * Resolves true when the answer was stored, false when this viewer had already
31
+ * answered — which is not an error, and is what a retried submission should report.
32
+ */
33
+ async answer(questionId, optionIndex, text) {
34
+ if (!this.options.streamId) {
35
+ throw new Error('replayLiveRoom needs a streamId to submit answers');
36
+ }
37
+ if (text && text.length > MAX_FREE_TEXT_LENGTH) {
38
+ throw new Error(`Answers are limited to ${MAX_FREE_TEXT_LENGTH} characters.`);
39
+ }
40
+ if (optionIndex === FREE_TEXT_ONLY && !text?.trim()) {
41
+ throw new Error('An answer must pick an option, write a reply, or both.');
42
+ }
43
+ const base = (this.options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
44
+ const url = `${base}/v1/${encodeURIComponent(this.options.streamId)}/questions/${encodeURIComponent(questionId)}/replay-answer`;
45
+ const doFetch = this.options.fetchImpl ?? globalThis.fetch;
46
+ const res = await doFetch(url, {
47
+ method: 'POST',
48
+ headers: { 'Content-Type': 'application/json' },
49
+ body: JSON.stringify({
50
+ sessionId: this.sessionId,
51
+ optionIndex,
52
+ ...(text ? { text } : {}),
53
+ }),
54
+ });
55
+ if (!res.ok) {
56
+ throw new Error(`Could not record answer (HTTP ${res.status})`);
57
+ }
58
+ this.answered.add(questionId);
59
+ const body = (await res.json().catch(() => null));
60
+ return body?.recorded ?? true;
61
+ }
62
+ /** Whether this viewer has answered the question during this session. */
63
+ hasAnswered(questionId) {
64
+ return this.answered.has(questionId);
65
+ }
66
+ on(event, listener) {
67
+ const set = this.listeners.get(event) ?? new Set();
68
+ set.add(listener);
69
+ this.listeners.set(event, set);
70
+ return () => set.delete(listener);
71
+ }
72
+ /**
73
+ * Drive from playback position. Call on `timeupdate` — a few times a second is
74
+ * plenty, since question offsets are seconds apart at best.
75
+ */
76
+ update(currentTimeSeconds) {
77
+ const seekedBackwards = currentTimeSeconds < this.lastTime - 1;
78
+ if (seekedBackwards)
79
+ this.rearmAfter(currentTimeSeconds);
80
+ this.lastTime = currentTimeSeconds;
81
+ // The most recent question whose moment has passed and is still within the
82
+ // window. Only that one is revealed, so a forward seek does not dump a backlog.
83
+ let candidate = null;
84
+ for (const q of this.questions) {
85
+ if (q.offsetSeconds > currentTimeSeconds)
86
+ break;
87
+ if (currentTimeSeconds - q.offsetSeconds > this.revealWindow)
88
+ continue;
89
+ candidate = q;
90
+ }
91
+ if (!candidate || this.revealed.has(candidate.id))
92
+ return;
93
+ this.revealed.add(candidate.id);
94
+ this.emit('question', {
95
+ id: candidate.id,
96
+ prompt: candidate.prompt,
97
+ options: candidate.options,
98
+ });
99
+ if (candidate.tally)
100
+ this.emit('tally', candidate.tally);
101
+ }
102
+ /** Questions after this point become eligible again, so a rewatch behaves like a first watch. */
103
+ rearmAfter(seconds) {
104
+ for (const q of this.questions) {
105
+ if (q.offsetSeconds >= seconds)
106
+ this.revealed.delete(q.id);
107
+ }
108
+ }
109
+ emit(event, payload) {
110
+ for (const listener of this.listeners.get(event) ?? []) {
111
+ try {
112
+ listener(payload);
113
+ }
114
+ catch (err) {
115
+ console.error(`[live-signaling] replay listener for "${event}" threw`, err);
116
+ }
117
+ }
118
+ }
119
+ }
120
+ export function replayLiveRoom(options) {
121
+ return new LiveRoomReplay(options);
122
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Viewer identity.
3
+ *
4
+ * The room deduplicates votes by session id, so every connection needs one. There are
5
+ * two sources, in order of preference:
6
+ *
7
+ * 1. A session id the host application already has — most usefully the one AntCDN's
8
+ * secure-playback handshake mints, which is server-issued and therefore not
9
+ * trivially reset by the viewer.
10
+ * 2. One this module generates and persists, for public streams where no handshake
11
+ * happens at all.
12
+ *
13
+ * The generated form is deliberately weak: clearing site data earns another vote. That
14
+ * matches the bar the feature actually sets — one vote per session, not per verified
15
+ * identity — and pretending otherwise would invite someone to rely on it.
16
+ */
17
+ /**
18
+ * Returns a stable session id, persisting a generated one when possible.
19
+ *
20
+ * Every storage access is guarded: private-mode browsers and embedded webviews throw on
21
+ * localStorage rather than returning null, and an identity failure must not stop a
22
+ * viewer from watching.
23
+ */
24
+ export declare function resolveSessionId(provided?: string): string;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Viewer identity.
3
+ *
4
+ * The room deduplicates votes by session id, so every connection needs one. There are
5
+ * two sources, in order of preference:
6
+ *
7
+ * 1. A session id the host application already has — most usefully the one AntCDN's
8
+ * secure-playback handshake mints, which is server-issued and therefore not
9
+ * trivially reset by the viewer.
10
+ * 2. One this module generates and persists, for public streams where no handshake
11
+ * happens at all.
12
+ *
13
+ * The generated form is deliberately weak: clearing site data earns another vote. That
14
+ * matches the bar the feature actually sets — one vote per session, not per verified
15
+ * identity — and pretending otherwise would invite someone to rely on it.
16
+ */
17
+ const STORAGE_KEY = 'antcdn.live-signaling.session';
18
+ /** Crypto-random where available, falling back so this never throws in odd runtimes. */
19
+ function randomId() {
20
+ const g = globalThis;
21
+ if (g.crypto?.randomUUID)
22
+ return g.crypto.randomUUID();
23
+ if (g.crypto?.getRandomValues) {
24
+ const bytes = new Uint8Array(16);
25
+ g.crypto.getRandomValues(bytes);
26
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
27
+ }
28
+ return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
29
+ }
30
+ /**
31
+ * Returns a stable session id, persisting a generated one when possible.
32
+ *
33
+ * Every storage access is guarded: private-mode browsers and embedded webviews throw on
34
+ * localStorage rather than returning null, and an identity failure must not stop a
35
+ * viewer from watching.
36
+ */
37
+ export function resolveSessionId(provided) {
38
+ if (provided)
39
+ return provided;
40
+ try {
41
+ const existing = globalThis.localStorage?.getItem(STORAGE_KEY);
42
+ if (existing)
43
+ return existing;
44
+ }
45
+ catch {
46
+ // Storage unavailable — fall through to an ephemeral id.
47
+ }
48
+ const generated = randomId();
49
+ try {
50
+ globalThis.localStorage?.setItem(STORAGE_KEY, generated);
51
+ }
52
+ catch {
53
+ // Not persistable. The id still works for this page's lifetime, which means
54
+ // the viewer can vote once here and once again after a reload.
55
+ }
56
+ return generated;
57
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@antcdn/live-signaling",
3
+ "version": "0.1.0",
4
+ "description": "Interactivity for AntCDN live streams and recordings: viewer count, questions, and voting, independent of any video player.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "sideEffects": false,
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "tsc": "tsc --noEmit",
24
+ "test": "vitest run",
25
+ "prepublishOnly": "npm run tsc && npm run test && npm run build"
26
+ },
27
+ "keywords": [
28
+ "antcdn",
29
+ "live",
30
+ "streaming",
31
+ "interactive",
32
+ "polls",
33
+ "websocket"
34
+ ],
35
+ "license": "MIT",
36
+ "devDependencies": {
37
+ "typescript": "^5.9.3",
38
+ "vitest": "^1.6.1"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/DavidQuartz/antcdn.git",
46
+ "directory": "typescript_root/live-signaling-sdk"
47
+ },
48
+ "homepage": "https://antcdn.net/interactive-live",
49
+ "bugs": {
50
+ "url": "https://github.com/DavidQuartz/antcdn/issues"
51
+ }
52
+ }