diff --git a/examples/package.json b/examples/package.json
index 2bc7219d0f..b8a58f6e4e 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -48,6 +48,7 @@
"pub-sub-rewind/react",
"pub-sub-rewind/javascript",
"pub-sub-message-annotations/javascript",
+ "pub-sub-live-voting/javascript",
"spaces-avatar-stack/react",
"spaces-avatar-stack/javascript",
"spaces-component-locking/react",
@@ -100,6 +101,7 @@
"pub-sub-rewind-javascript": "yarn workspace pub-sub-rewind-javascript dev",
"pub-sub-rewind-react": "yarn workspace pub-sub-rewind-react dev",
"pub-sub-message-annotations-javascript": "yarn workspace pub-sub-message-annotations-javascript dev",
+ "pub-sub-live-voting-javascript": "yarn workspace pub-sub-live-voting-javascript dev",
"spaces-avatar-stack-javascript": "yarn workspace spaces-avatar-stack-javascript dev",
"spaces-avatar-stack-react": "yarn workspace spaces-avatar-stack-react dev",
"spaces-component-locking-javascript": "yarn workspace spaces-component-locking-javascript dev",
@@ -119,6 +121,7 @@
"lodash": "^4.18.1",
"minifaker": "^1.34.1",
"nanoid": "^5.0.7",
+ "qrcode": "^1.5.4",
"react": "^18",
"react-dom": "^18",
"react-icons": "^5.4.0",
@@ -130,6 +133,7 @@
"@tailwindcss/postcss": "^4.0.14",
"@types/express": "^5.0.0",
"@types/node": "^20",
+ "@types/qrcode": "^1.5.5",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/uikit": "^3.7.0",
diff --git a/examples/pub-sub-live-voting/javascript/.gitignore b/examples/pub-sub-live-voting/javascript/.gitignore
new file mode 100644
index 0000000000..804bd4a4ae
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+*.local
diff --git a/examples/pub-sub-live-voting/javascript/README.md b/examples/pub-sub-live-voting/javascript/README.md
new file mode 100644
index 0000000000..941c4ebbed
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/README.md
@@ -0,0 +1,161 @@
+# Build live voting with message annotations
+
+This is a live-voting app: an admin runs a show of polls, an audience votes
+from their phones, and a big screen shows results updating in realtime. The
+voter pane and presenter pane on the left are both live: cast a vote and watch
+the presenter's heatmap and vote bubbles react instantly.
+
+The design pattern this example highlights is how a vote is represented. A poll
+is a regular Ably **message**. Each vote is an **annotation** attached to that
+message. Ably aggregates the annotations into a **summary**, attached to the
+original poll message, and delivers it to subscribers. Votes do not need to be
+stored in a database, and your own server is only needed to autenticate
+clients, all the realtime low-latency vote distribution and counting is done by
+Ably.
+
+## Why annotations?
+
+There are a few different patterns you could use to build a voting app.
+Firstly, of course, you could just use regular messages. Secondly, you could
+use [LiveObjects](https://ably.com/docs/liveobjects), an ably feature which
+attaches a synchonized, conflict-free, concurrently-modifyable state object to
+a channel. Either would be an excellent choice, but
+[annotations](https://ably.com/docs/messages/annotations) have some advantages
+that make it well-suited to this use-case:
+
+**Summaries and raw events are separate streams, so each view subscribes to
+what it needs.** Voters subscribe to the annotation *summary* only — one
+compact, regularly-rolled-up object (`{ optionId: { total } }`) with enough information to
+show live percentages on every phone. A busy poll is one cheap summary update
+per phone, not one event per vote. The presenter view, however, does want to
+subscribe to individual annotation events, one per vote, so it can to animate a
+bubble for each (together with the user id of the person who made that vote).
+It opts into that higher-volume stream separately by specifying the
+[`ANNOTATION_SUBSCRIBE` channel
+mode](https://ably.com/docs/messages/annotations#individual-annotations) and
+add a listener with with `annotations.subscribe()`. Same data, two consumption
+modes, each scoped to the view that needs it.
+
+**Capabilities keep voters locked down.** A voter's token grants only the
+`annotation-publish` capability on the channel. A voter can cast votes but
+cannot publish poll messages, and cannot subscribe to other voters' raw
+annotations, so it can't tamper with the poll or snoop on individual votes. See
+the per-role capabilities in
+[`server/src/server.ts`](https://github.com/ably-demos/live-voting-with-annotations/blob/main/server/src/server.ts).
+
+**One vote per person, enforced by Ably.** Votes use the `unique` aggregation
+(`vote:unique.v1`). In this aggregation mode, Ably keeps at most one vote per
+`clientId`, and moving to another option moves that client's vote rather than
+adding a second one. You get "one person, one vote" semantics for free.
+
+If building on [LiveObjects](/docs/liveobjects), you'd use a `LiveCounter` per
+option, but there's no built-in one-vote-per-client constraint — any client can
+call `increment` as many times as it likes. You could add a separate `LiveMap`
+tracking `clientId → vote` and check it before allowing a vote, but there's no
+atomic check-and-increment. Of course, LiveObjects have other advantages -- and
+if some part of your app needs the features of liveobjects, you can of course
+use a LiveObject on the same channel as well.
+
+## How it works
+
+1. The admin starts a poll by publishing a message:
+
+ ```javascript
+ await channel.publish('poll', { pollId, question, type, options });
+ ```
+
+2. A voter attaches a `vote:unique.v1` annotation to that message's `serial`,
+ naming the chosen option:
+
+ ```javascript
+ await channel.annotations.publish(pollSerial, {
+ type: 'vote:unique.v1',
+ name: optionId,
+ });
+ ```
+
+3. Ably aggregates the votes and delivers a summary on the poll message. Voters
+ read it to render live percentages:
+
+ ```javascript
+ channel.subscribe((message) => {
+ const summary = message.annotations?.summary?.['vote:unique.v1'];
+ // summary[optionId].total === votes for that option
+ });
+ ```
+
+4. The presenter additionally subscribes to the individual events for its vote
+ and suggestion bubbles:
+
+ ```javascript
+ channel.annotations.subscribe('vote:unique.v1', (annotation) => {
+ // one event per vote — annotation.name is the option, annotation.clientId the voter
+ });
+ ```
+
+The annotation type is written `namespace:summarization.version` — here
+`vote`, `unique`, `v1`. Read more about the aggregation types (`unique`,
+`distinct`, `multiple`, `total`, `flag`) in
+[Message annotations](/docs/messages/annotations).
+
+The channel is attached with `rewind: 1`, so a phone that joins (or refreshes)
+mid-poll immediately receives the current poll and its latest summary without
+the admin re-publishing.
+
+## Unaggregated annotations
+
+Open-ended "suggest" polls (where voters type free text instead of picking an
+option) use a second annotation type, `suggestion`, with no aggregation suffix.
+Votes need a running tally, so they use `unique` and are read from the summary.
+A suggestion is a one-off piece of text that floats across the presenter once
+and is gone, with nothing to count. Since they are unaggregated, they are
+received by anyone subscribing to annotations, and they are retrievable through
+anyone using
+[annotations.get()](https://ably.com/docs/messages/annotations#retrieve) to
+retrieve a list of annotations for a given message, but they don't result in a
+new message summary being generated.
+
+## Server-side batching
+
+For very high usecases, where individual votes might (say) overwhelm the
+presenter view's message rate limits, you can turn on [server-side
+batching](/docs/messages/batch) for the `voting` channel namespace so Ably
+groups messages over a short interval (for example 100ms) before fanning them
+out. Each batch counts as a single message, which keeps cost and rate-limits in
+check during surges with only a small, configurable delay. Create the rule in
+your app settings, or via the Control API / CLI:
+
+```shell
+ably apps rules create --name "voting" --batching-enabled --batching-interval 100
+```
+
+## Required channel rule
+
+Annotations require the *Message annotations, updates, deletes, and appends*
+rule to be enabled on the channel or namespace. Enable it for the `voting`
+namespace before running this against your own app.
+
+## Getting started
+
+The live demo above runs entirely in the browser. For a complete app you can
+clone and run yourself — admin, voter and presenter, with proper token
+authentication and none of the in-page scaffolding — get
+[ably-demos/live-voting-with-annotations](https://github.com/ably-demos/live-voting-with-annotations).
+
+Its README has the full setup, but in short: give the server an Ably API key and
+an admin password, pick the static (`SHOWS_FILE`) or Postgres poll store, then
+run the server and the client. The default view is the voter; `?role=admin`
+drives the show and `?role=presenter` is the big screen, and the admin shows a QR
+code that points voters at the right session.
+
+The client never sees an API key — it calls the server's `/auth` endpoint for
+short-lived, role-scoped tokens. The hosted demo above has no backend, so it uses
+a raw key embedded in the page; that's just for the demo and should never be done
+in a real app.
+
+## Related
+
+- [Message annotations](/docs/messages/annotations) — the full feature reference.
+- [Message annotations example](/examples/pub-sub-message-annotations) — a
+ lower-level tour of all five aggregation types.
+- [Message batching](/docs/messages/batch) — scaling high-throughput channels.
diff --git a/examples/pub-sub-live-voting/javascript/data/demo-shows.json b/examples/pub-sub-live-voting/javascript/data/demo-shows.json
new file mode 100644
index 0000000000..ce877c4447
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/data/demo-shows.json
@@ -0,0 +1,38 @@
+{
+ "shows": [
+ {
+ "id": 1,
+ "name": "Ably Live Voting demo",
+ "polls": [
+ {
+ "id": 1,
+ "question": "Which real-time feature do you reach for most?",
+ "type": "list",
+ "options": [
+ { "id": 101, "label": "Pub/Sub channels" },
+ { "id": 102, "label": "Presence" },
+ { "id": 103, "label": "Message history" },
+ { "id": 104, "label": "Push notifications" }
+ ]
+ },
+ {
+ "id": 2,
+ "question": "Rate this demo with the d-pad",
+ "type": "dpad",
+ "options": [
+ { "id": 201, "label": "Love it", "direction": "up" },
+ { "id": 202, "label": "It's neat", "direction": "right" },
+ { "id": 203, "label": "Meh", "direction": "down" },
+ { "id": 204, "label": "Confused", "direction": "left" }
+ ]
+ },
+ {
+ "id": 3,
+ "question": "What should we build with Ably next?",
+ "type": "suggest",
+ "options": []
+ }
+ ]
+ }
+ ]
+}
diff --git a/examples/pub-sub-live-voting/javascript/database/migrations/001_poll_types.sql b/examples/pub-sub-live-voting/javascript/database/migrations/001_poll_types.sql
new file mode 100644
index 0000000000..0eaa99d8b7
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/database/migrations/001_poll_types.sql
@@ -0,0 +1,7 @@
+ALTER TABLE polls
+ ADD COLUMN IF NOT EXISTS type TEXT NOT NULL DEFAULT 'list'
+ CHECK (type IN ('list', 'dpad'));
+
+ALTER TABLE poll_options
+ ADD COLUMN IF NOT EXISTS direction TEXT
+ CHECK (direction IS NULL OR direction IN ('up', 'right', 'down', 'left'));
diff --git a/examples/pub-sub-live-voting/javascript/database/migrations/002_suggest_poll_type.sql b/examples/pub-sub-live-voting/javascript/database/migrations/002_suggest_poll_type.sql
new file mode 100644
index 0000000000..d2d180a737
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/database/migrations/002_suggest_poll_type.sql
@@ -0,0 +1,3 @@
+ALTER TABLE polls DROP CONSTRAINT IF EXISTS polls_type_check;
+ALTER TABLE polls ADD CONSTRAINT polls_type_check
+ CHECK (type IN ('list', 'dpad', 'suggest'));
diff --git a/examples/pub-sub-live-voting/javascript/database/seed.sql b/examples/pub-sub-live-voting/javascript/database/seed.sql
new file mode 100644
index 0000000000..633bd85c27
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/database/seed.sql
@@ -0,0 +1,18 @@
+-- Optional sample data for the Postgres store. The d-pad and suggest poll
+-- types make the presenter view more expressive than list polls alone.
+INSERT INTO shows (name) VALUES ('Ably Live Voting demo');
+
+INSERT INTO polls (show_id, question, type, sort_order) VALUES
+ (1, 'Which real-time feature do you reach for most?', 'list', 0),
+ (1, 'Rate this demo with the d-pad', 'dpad', 1),
+ (1, 'What should we build with Ably next?', 'suggest', 2);
+
+INSERT INTO poll_options (poll_id, label, direction, sort_order) VALUES
+ (1, 'Pub/Sub channels', NULL, 0),
+ (1, 'Presence', NULL, 1),
+ (1, 'Message history', NULL, 2),
+ (1, 'Push notifications', NULL, 3),
+ (2, 'Love it', 'up', 0),
+ (2, 'It''s neat', 'right', 1),
+ (2, 'Meh', 'down', 2),
+ (2, 'Confused', 'left', 3);
diff --git a/examples/pub-sub-live-voting/javascript/database/setup.sql b/examples/pub-sub-live-voting/javascript/database/setup.sql
new file mode 100644
index 0000000000..d71ade40bc
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/database/setup.sql
@@ -0,0 +1,24 @@
+CREATE TABLE shows (
+ id SERIAL PRIMARY KEY,
+ name TEXT NOT NULL
+);
+
+CREATE TABLE polls (
+ id SERIAL PRIMARY KEY,
+ show_id INTEGER NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
+ question TEXT NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ type TEXT NOT NULL DEFAULT 'list'
+ CHECK (type IN ('list', 'dpad', 'suggest'))
+);
+
+CREATE TABLE poll_options (
+ id SERIAL PRIMARY KEY,
+ poll_id INTEGER NOT NULL REFERENCES polls(id) ON DELETE CASCADE,
+ label TEXT NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ direction TEXT
+ CHECK (direction IS NULL OR direction IN (
+ 'up', 'right', 'down', 'left'
+ ))
+);
diff --git a/examples/pub-sub-live-voting/javascript/index.html b/examples/pub-sub-live-voting/javascript/index.html
new file mode 100644
index 0000000000..cf21dc306f
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+ Ably Live Voting
+
+
+
+
+
+
+
+
diff --git a/examples/pub-sub-live-voting/javascript/package.json b/examples/pub-sub-live-voting/javascript/package.json
new file mode 100644
index 0000000000..3425e78671
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "pub-sub-live-voting-javascript",
+ "version": "1.0.0",
+ "main": "index.js",
+ "license": "MIT",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ }
+}
diff --git a/examples/pub-sub-live-voting/javascript/server/.env.example b/examples/pub-sub-live-voting/javascript/server/.env.example
new file mode 100644
index 0000000000..573433b69c
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/server/.env.example
@@ -0,0 +1,14 @@
+# Your Ably API key, in "keyName:keySecret" form. Used to sign role-scoped JWTs.
+ABLY_API_KEY=
+
+# Password the admin console must supply to drive a show.
+ADMIN_PASSWORD=changeme
+
+# Read polls from a static JSON file (no database). Omit to use Postgres.
+# Path is resolved relative to the server's working directory.
+SHOWS_FILE=../data/demo-shows.json
+
+# Postgres connection string (only used when SHOWS_FILE is unset).
+# DATABASE_URL=postgresql://localhost/ably_voting
+
+# PORT=3000
diff --git a/examples/pub-sub-live-voting/javascript/server/package.json b/examples/pub-sub-live-voting/javascript/server/package.json
new file mode 100644
index 0000000000..eb1e196fd4
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/server/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "pub-sub-live-voting-server",
+ "version": "1.0.0",
+ "type": "module",
+ "main": "src/server.ts",
+ "license": "MIT",
+ "scripts": {
+ "dev": "tsx watch src/server.ts",
+ "start": "tsx src/server.ts"
+ },
+ "dependencies": {
+ "cors": "^2.8.5",
+ "express": "^4.21.0",
+ "jsonwebtoken": "^9.0.0",
+ "pg": "^8.20.0"
+ },
+ "devDependencies": {
+ "@types/cors": "^2.8.17",
+ "@types/express": "^4.17.21",
+ "@types/jsonwebtoken": "^9.0.0",
+ "@types/node": "^20",
+ "@types/pg": "^8.20.0",
+ "tsx": "^4.19.0",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/examples/pub-sub-live-voting/javascript/server/src/db.ts b/examples/pub-sub-live-voting/javascript/server/src/db.ts
new file mode 100644
index 0000000000..47ac7be578
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/server/src/db.ts
@@ -0,0 +1,177 @@
+import pg from 'pg';
+import type { OptionInput, ShowStore } from './showStore.js';
+
+// Lazily constructed so that static (SHOWS_FILE) deployments never open — or
+// even configure — a database connection.
+let _pool: pg.Pool | null = null;
+function pool(): pg.Pool {
+ if (!_pool) {
+ _pool = new pg.Pool({
+ connectionString: process.env.DATABASE_URL || 'postgresql://localhost/ably_voting',
+ });
+ }
+ return _pool;
+}
+
+async function getShows() {
+ const { rows } = await pool().query(`
+ SELECT s.id, s.name, COUNT(p.id)::int AS poll_count
+ FROM shows s
+ LEFT JOIN polls p ON p.show_id = s.id
+ GROUP BY s.id
+ ORDER BY s.id
+ `);
+ return rows;
+}
+
+async function createShow(name: string) {
+ const { rows } = await pool().query(
+ 'INSERT INTO shows (name) VALUES ($1) RETURNING id, name',
+ [name],
+ );
+ return rows[0];
+}
+
+async function updateShow(id: number, name: string) {
+ const { rowCount } = await pool().query(
+ 'UPDATE shows SET name = $1 WHERE id = $2',
+ [name, id],
+ );
+ return rowCount! > 0;
+}
+
+async function deleteShow(id: number) {
+ const { rowCount } = await pool().query('DELETE FROM shows WHERE id = $1', [id]);
+ return rowCount! > 0;
+}
+
+async function getShowPolls(showId: number) {
+ const showResult = await pool().query('SELECT id, name FROM shows WHERE id = $1', [showId]);
+ if (showResult.rows.length === 0) return null;
+
+ const pollRows = await pool().query(
+ 'SELECT id, question, type, sort_order FROM polls WHERE show_id = $1 ORDER BY sort_order, id',
+ [showId],
+ );
+
+ const optionRows = await pool().query(
+ `SELECT po.id, po.poll_id, po.label, po.direction, po.sort_order
+ FROM poll_options po
+ JOIN polls p ON p.id = po.poll_id
+ WHERE p.show_id = $1
+ ORDER BY po.sort_order, po.id`,
+ [showId],
+ );
+
+ const optionsByPoll = new Map>();
+ for (const opt of optionRows.rows) {
+ if (!optionsByPoll.has(opt.poll_id)) optionsByPoll.set(opt.poll_id, []);
+ optionsByPoll.get(opt.poll_id)!.push({
+ id: opt.id, label: opt.label, direction: opt.direction, sort_order: opt.sort_order,
+ });
+ }
+
+ const polls = pollRows.rows.map((p) => ({
+ id: p.id,
+ question: p.question,
+ type: p.type,
+ sort_order: p.sort_order,
+ options: optionsByPoll.get(p.id) ?? [],
+ }));
+
+ return { show: showResult.rows[0], polls };
+}
+
+async function createPoll(showId: number, type: string, question: string, options: OptionInput[]) {
+ const client = await pool().connect();
+ try {
+ await client.query('BEGIN');
+
+ const maxOrder = await client.query(
+ 'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM polls WHERE show_id = $1',
+ [showId],
+ );
+
+ const pollResult = await client.query(
+ 'INSERT INTO polls (show_id, question, type, sort_order) VALUES ($1, $2, $3, $4) RETURNING id',
+ [showId, question, type, maxOrder.rows[0].next],
+ );
+ const pollId = pollResult.rows[0].id;
+
+ for (let i = 0; i < options.length; i++) {
+ await client.query(
+ 'INSERT INTO poll_options (poll_id, label, direction, sort_order) VALUES ($1, $2, $3, $4)',
+ [pollId, options[i].label, options[i].direction ?? null, i],
+ );
+ }
+
+ await client.query('COMMIT');
+ return pollId;
+ } catch (err) {
+ await client.query('ROLLBACK');
+ throw err;
+ } finally {
+ client.release();
+ }
+}
+
+async function updatePoll(pollId: number, type: string, question: string, options: OptionInput[]) {
+ const client = await pool().connect();
+ try {
+ await client.query('BEGIN');
+
+ await client.query('UPDATE polls SET question = $1, type = $2 WHERE id = $3', [question, type, pollId]);
+ await client.query('DELETE FROM poll_options WHERE poll_id = $1', [pollId]);
+
+ for (let i = 0; i < options.length; i++) {
+ await client.query(
+ 'INSERT INTO poll_options (poll_id, label, direction, sort_order) VALUES ($1, $2, $3, $4)',
+ [pollId, options[i].label, options[i].direction ?? null, i],
+ );
+ }
+
+ await client.query('COMMIT');
+ } catch (err) {
+ await client.query('ROLLBACK');
+ throw err;
+ } finally {
+ client.release();
+ }
+}
+
+async function deletePoll(pollId: number) {
+ const { rowCount } = await pool().query('DELETE FROM polls WHERE id = $1', [pollId]);
+ return rowCount! > 0;
+}
+
+async function reorderPolls(showId: number, pollIds: number[]) {
+ const client = await pool().connect();
+ try {
+ await client.query('BEGIN');
+ for (let i = 0; i < pollIds.length; i++) {
+ await client.query(
+ 'UPDATE polls SET sort_order = $1 WHERE id = $2 AND show_id = $3',
+ [i, pollIds[i], showId],
+ );
+ }
+ await client.query('COMMIT');
+ } catch (err) {
+ await client.query('ROLLBACK');
+ throw err;
+ } finally {
+ client.release();
+ }
+}
+
+export const pgStore: ShowStore = {
+ readOnly: false,
+ getShows,
+ getShowPolls,
+ createShow,
+ updateShow,
+ deleteShow,
+ createPoll,
+ updatePoll,
+ deletePoll,
+ reorderPolls,
+};
diff --git a/examples/pub-sub-live-voting/javascript/server/src/server.ts b/examples/pub-sub-live-voting/javascript/server/src/server.ts
new file mode 100644
index 0000000000..a0b586fb57
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/server/src/server.ts
@@ -0,0 +1,326 @@
+import express from 'express';
+import cors from 'cors';
+import jwt from 'jsonwebtoken';
+import path from 'path';
+import { existsSync } from 'fs';
+import { fileURLToPath } from 'url';
+import type { OptionInput, ShowStore } from './showStore.js';
+import { pgStore } from './db.js';
+import { createStaticStore } from './staticStore.js';
+
+// The backend has two jobs:
+// 1. Issue short-lived, role-scoped Ably tokens (GET /auth). This is the
+// important bit for the tutorial — a voter token can publish annotations
+// and nothing else.
+// 2. Serve the poll definitions to the admin (GET /api/shows...), from either
+// a static JSON file (SHOWS_FILE) or Postgres.
+// It never touches votes — those live entirely as Ably annotations.
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const ABLY_API_KEY = process.env.ABLY_API_KEY;
+if (!ABLY_API_KEY) {
+ console.error('ABLY_API_KEY environment variable is required');
+ process.exit(1);
+}
+
+const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
+if (!ADMIN_PASSWORD) {
+ console.error('ADMIN_PASSWORD environment variable is required');
+ process.exit(1);
+}
+
+const [keyName, keySecret] = ABLY_API_KEY.split(':');
+
+// Data source: a JSON file (read-only, no database) when SHOWS_FILE is set,
+// otherwise Postgres. Resolved relative to the working directory.
+const SHOWS_FILE = process.env.SHOWS_FILE;
+let store: ShowStore;
+try {
+ store = SHOWS_FILE ? createStaticStore(path.resolve(SHOWS_FILE)) : pgStore;
+} catch (err: any) {
+ console.error(err.message);
+ process.exit(1);
+}
+console.log(store.readOnly
+ ? `Using read-only static show data from ${SHOWS_FILE}`
+ : 'Using Postgres show store');
+
+const app = express();
+
+// The client is a separate Vite app. In production it can be served from the
+// same origin (see the static block below); in development it runs on Vite's
+// dev server and reaches the API through Vite's proxy. CORS keeps direct
+// cross-origin calls working too.
+app.use(cors({ origin: true, methods: ['GET', 'POST', 'PUT', 'DELETE'] }));
+app.use(express.json());
+
+function fail(res: express.Response, label: string, err: any) {
+ console.error(`${label} failed:`, err);
+ const message = err?.message || err?.code || String(err);
+ res.status(500).json({ error: message });
+}
+
+// Basic auth middleware for admin routes. Only the password half is checked —
+// the client sends `Basic base64("admin:")`.
+function requireAdmin(req: express.Request, res: express.Response, next: express.NextFunction) {
+ const auth = req.headers.authorization;
+ if (!auth?.startsWith('Basic ')) {
+ res.set('WWW-Authenticate', 'Basic realm="Admin"');
+ res.status(401).send('Authentication required');
+ return;
+ }
+ const decoded = Buffer.from(auth.slice(6), 'base64').toString();
+ const password = decoded.split(':').slice(1).join(':');
+ if (password !== ADMIN_PASSWORD) {
+ res.set('WWW-Authenticate', 'Basic realm="Admin"');
+ res.status(401).send('Invalid password');
+ return;
+ }
+ next();
+}
+
+// Reject writes when the active store is read-only (static demo data).
+function requireWritable(_req: express.Request, res: express.Response, next: express.NextFunction) {
+ if (store.readOnly) {
+ res.status(403).json({ error: 'This is a read-only demo — editing is disabled.' });
+ return;
+ }
+ next();
+}
+
+// Lets the admin client discover whether editing is available (static vs db).
+app.get('/api/config', (_req, res) => {
+ res.json({ readOnly: store.readOnly });
+});
+
+// Admin API — all behind basic auth
+app.get('/api/shows', requireAdmin, async (_req, res) => {
+ try {
+ res.json(await store.getShows());
+ } catch (err: any) {
+ fail(res, 'GET /api/shows', err);
+ }
+});
+
+app.post('/api/shows', requireAdmin, requireWritable, async (req, res) => {
+ const { name } = req.body;
+ if (!name) { res.status(400).json({ error: 'Missing name' }); return; }
+ try {
+ res.json(await store.createShow(name));
+ } catch (err: any) {
+ fail(res, 'POST /api/shows', err);
+ }
+});
+
+app.put('/api/shows/:id', requireAdmin, requireWritable, async (req, res) => {
+ const { name } = req.body;
+ if (!name) { res.status(400).json({ error: 'Missing name' }); return; }
+ try {
+ const ok = await store.updateShow(Number(req.params.id), name);
+ if (!ok) { res.status(404).json({ error: 'Not found' }); return; }
+ res.json({ ok: true });
+ } catch (err: any) {
+ fail(res, `PUT /api/shows/${req.params.id}`, err);
+ }
+});
+
+app.delete('/api/shows/:id', requireAdmin, requireWritable, async (req, res) => {
+ try {
+ const ok = await store.deleteShow(Number(req.params.id));
+ if (!ok) { res.status(404).json({ error: 'Not found' }); return; }
+ res.json({ ok: true });
+ } catch (err: any) {
+ fail(res, `DELETE /api/shows/${req.params.id}`, err);
+ }
+});
+
+// Polls within a show — read endpoint is public (presenter needs it)
+app.get('/api/shows/:showId/polls', async (req, res) => {
+ try {
+ const result = await store.getShowPolls(Number(req.params.showId));
+ if (!result) { res.status(404).json({ error: 'Show not found' }); return; }
+ // Convert numeric option IDs to strings for Ably annotation compatibility
+ const polls = result.polls.map((p) => ({
+ ...p,
+ options: p.options.map((o) => ({
+ id: String(o.id),
+ label: o.label,
+ ...(o.direction ? { direction: o.direction } : {}),
+ })),
+ }));
+ res.json({ show: result.show, polls });
+ } catch (err: any) {
+ fail(res, `GET /api/shows/${req.params.showId}/polls`, err);
+ }
+});
+
+const DPAD_SLOTS = new Set(['up', 'right', 'down', 'left']);
+
+const VALID_TYPES = new Set(['list', 'dpad', 'suggest']);
+const ALL_SLOTS = new Set(DPAD_SLOTS);
+
+function validatePollInput(body: any): { type: string; question: string; options: OptionInput[] } | string {
+ const { question } = body;
+ const type = body.type ?? 'list';
+ if (!VALID_TYPES.has(type)) return 'Invalid poll type';
+ if (!question || typeof question !== 'string') return 'Missing question';
+
+ if (type === 'suggest') {
+ return { type, question, options: [] };
+ }
+
+ const options = body.options;
+ if (!Array.isArray(options) || options.length === 0) return 'Missing options';
+
+ const normalized: OptionInput[] = [];
+ const seenDirections = new Set();
+ for (const o of options) {
+ if (typeof o === 'string') {
+ normalized.push({ label: o });
+ } else if (o && typeof o.label === 'string') {
+ const direction = o.direction ?? null;
+ if (direction !== null) {
+ if (!ALL_SLOTS.has(direction)) return `Invalid direction: ${direction}`;
+ if (seenDirections.has(direction)) return `Duplicate direction: ${direction}`;
+ seenDirections.add(direction);
+ }
+ normalized.push({ label: o.label, direction });
+ } else {
+ return 'Invalid option entry';
+ }
+ }
+
+ if (type === 'dpad') {
+ if (normalized.some((o) => !o.direction)) return 'Every d-pad option needs a slot';
+ if (normalized.some((o) => o.direction && !DPAD_SLOTS.has(o.direction))) {
+ return 'Invalid slot for d-pad poll';
+ }
+ if (normalized.length > DPAD_SLOTS.size) return `A d-pad poll has at most ${DPAD_SLOTS.size} options`;
+ }
+
+ return { type, question, options: normalized };
+}
+
+app.post('/api/shows/:showId/polls', requireAdmin, requireWritable, async (req, res) => {
+ const parsed = validatePollInput(req.body);
+ if (typeof parsed === 'string') { res.status(400).json({ error: parsed }); return; }
+ try {
+ const pollId = await store.createPoll(Number(req.params.showId), parsed.type, parsed.question, parsed.options);
+ res.json({ id: pollId });
+ } catch (err: any) {
+ fail(res, `POST /api/shows/${req.params.showId}/polls`, err);
+ }
+});
+
+app.put('/api/shows/:showId/polls/reorder', requireAdmin, requireWritable, async (req, res) => {
+ const { order } = req.body;
+ if (!Array.isArray(order)) {
+ res.status(400).json({ error: 'Missing order array' });
+ return;
+ }
+ try {
+ await store.reorderPolls(Number(req.params.showId), order);
+ res.json({ ok: true });
+ } catch (err: any) {
+ fail(res, `PUT /api/shows/${req.params.showId}/polls/reorder`, err);
+ }
+});
+
+app.put('/api/shows/:showId/polls/:pollId', requireAdmin, requireWritable, async (req, res) => {
+ const parsed = validatePollInput(req.body);
+ if (typeof parsed === 'string') { res.status(400).json({ error: parsed }); return; }
+ try {
+ await store.updatePoll(Number(req.params.pollId), parsed.type, parsed.question, parsed.options);
+ res.json({ ok: true });
+ } catch (err: any) {
+ fail(res, `PUT /api/shows/${req.params.showId}/polls/${req.params.pollId}`, err);
+ }
+});
+
+app.delete('/api/shows/:showId/polls/:pollId', requireAdmin, requireWritable, async (req, res) => {
+ try {
+ const ok = await store.deletePoll(Number(req.params.pollId));
+ if (!ok) { res.status(404).json({ error: 'Not found' }); return; }
+ res.json({ ok: true });
+ } catch (err: any) {
+ fail(res, `DELETE /api/shows/${req.params.showId}/polls/${req.params.pollId}`, err);
+ }
+});
+
+// ── Ably token auth ──
+//
+// The heart of the security model. Each role gets a JWT whose capabilities are
+// scoped to exactly what that role needs on this one channel:
+// - voter: annotation-publish only (cast votes; can't read others' votes
+// or publish poll messages)
+// - presenter: annotation-subscribe + subscribe (read-only)
+// - admin: publish + subscribe + annotation-subscribe (drives the show)
+// The clientId baked into the token is what makes `vote:unique.v1` enforce one
+// vote per person.
+app.get('/auth', (req, res) => {
+ const clientId = req.query.clientId as string;
+ const sessionId = req.query.sessionId as string;
+ const role = req.query.role as string;
+
+ if (!clientId || !sessionId || !role) {
+ res.status(400).json({ error: 'Missing clientId, sessionId, or role' });
+ return;
+ }
+
+ if (role === 'admin') {
+ const password = req.query.password as string;
+ if (password !== ADMIN_PASSWORD) {
+ res.status(403).json('Invalid admin password');
+ return;
+ }
+ }
+
+ const channelName = `voting:${sessionId}`;
+ const capsByRole: Record = {
+ admin: ['publish', 'subscribe', 'annotation-subscribe'],
+ voter: ['subscribe', 'annotation-publish'],
+ presenter: ['subscribe', 'annotation-subscribe'],
+ };
+ const caps = capsByRole[role];
+ if (!caps) {
+ res.status(400).json({ error: 'Invalid role' });
+ return;
+ }
+ const capabilities: Record = { [channelName]: caps };
+
+ const now = Math.floor(Date.now() / 1000);
+ const token = jwt.sign(
+ {
+ 'x-ably-capability': JSON.stringify(capabilities),
+ 'x-ably-clientId': clientId,
+ iat: now,
+ exp: now + 3600,
+ },
+ keySecret,
+ {
+ header: {
+ typ: 'JWT',
+ alg: 'HS256',
+ kid: keyName,
+ },
+ },
+ );
+
+ res.set('Content-Type', 'application/jwt');
+ res.send(token);
+});
+
+// Optionally serve the built client (../../dist, i.e. the javascript/ project
+// root) from the same origin, so a production deploy needs only this one
+// server. In development you'd run the Vite dev server instead and let it proxy
+// the API here.
+const clientDist = path.resolve(__dirname, '../../dist');
+if (existsSync(clientDist)) {
+ app.use(express.static(clientDist));
+}
+
+const PORT = process.env.PORT || 3000;
+app.listen(PORT, () => {
+ console.log(`Server running on http://localhost:${PORT}`);
+});
diff --git a/examples/pub-sub-live-voting/javascript/server/src/showStore.ts b/examples/pub-sub-live-voting/javascript/server/src/showStore.ts
new file mode 100644
index 0000000000..67c00ff7dc
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/server/src/showStore.ts
@@ -0,0 +1,54 @@
+// Data layer behind the admin API. Two implementations: a Postgres store
+// (db.ts) for full read/write, and a read-only static store (staticStore.ts)
+// backed by a JSON file for DB-less demo deployments. The server picks one at
+// startup based on the SHOWS_FILE env var.
+
+export type Slot = 'up' | 'right' | 'down' | 'left';
+
+export interface OptionInput {
+ label: string;
+ direction?: Slot | null;
+}
+
+export interface ShowSummary {
+ id: number;
+ name: string;
+ poll_count: number;
+}
+
+export interface StoredOption {
+ id: number;
+ label: string;
+ direction: string | null;
+ sort_order: number;
+}
+
+export interface StoredPoll {
+ id: number;
+ question: string;
+ type: string;
+ sort_order: number;
+ options: StoredOption[];
+}
+
+export interface ShowPolls {
+ show: { id: number; name: string };
+ polls: StoredPoll[];
+}
+
+export interface ShowStore {
+ /** When true, the write methods are unavailable and the admin UI is read-only. */
+ readonly readOnly: boolean;
+
+ getShows(): Promise;
+ getShowPolls(showId: number): Promise;
+
+ createShow(name: string): Promise<{ id: number; name: string }>;
+ updateShow(id: number, name: string): Promise;
+ deleteShow(id: number): Promise;
+
+ createPoll(showId: number, type: string, question: string, options: OptionInput[]): Promise;
+ updatePoll(pollId: number, type: string, question: string, options: OptionInput[]): Promise;
+ deletePoll(pollId: number): Promise;
+ reorderPolls(showId: number, pollIds: number[]): Promise;
+}
diff --git a/examples/pub-sub-live-voting/javascript/server/src/staticStore.ts b/examples/pub-sub-live-voting/javascript/server/src/staticStore.ts
new file mode 100644
index 0000000000..c22196795e
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/server/src/staticStore.ts
@@ -0,0 +1,92 @@
+import { readFileSync } from 'fs';
+import type {
+ ShowStore, ShowSummary, ShowPolls, StoredPoll, StoredOption,
+} from './showStore.js';
+
+// JSON authoring format (see ../../data/demo-shows.json):
+// { "shows": [ { "id", "name", "polls": [
+// { "id", "question", "type", "options": [ { "id", "label", "direction"? } ] }
+// ] } ] }
+interface RawOption { id: number; label: string; direction?: string | null; }
+interface RawPoll { id: number; question: string; type: string; options?: RawOption[]; }
+interface RawShow { id: number; name: string; polls?: RawPoll[]; }
+
+const VALID_TYPES = new Set(['list', 'dpad', 'suggest']);
+
+function readonlyWrite(): never {
+ throw new Error('Static show store is read-only; editing is disabled.');
+}
+
+function parseShows(filePath: string): RawShow[] {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
+ } catch (err: any) {
+ throw new Error(`Failed to read SHOWS_FILE "${filePath}": ${err.message}`);
+ }
+
+ const shows = (parsed as { shows?: unknown })?.shows;
+ if (!Array.isArray(shows)) {
+ throw new Error(`SHOWS_FILE "${filePath}" must contain a top-level "shows" array.`);
+ }
+
+ for (const show of shows as RawShow[]) {
+ if (typeof show.id !== 'number' || typeof show.name !== 'string') {
+ throw new Error('Each show needs a numeric "id" and a string "name".');
+ }
+ for (const poll of show.polls ?? []) {
+ if (typeof poll.id !== 'number' || typeof poll.question !== 'string') {
+ throw new Error(`Show ${show.id}: each poll needs a numeric "id" and a string "question".`);
+ }
+ if (!VALID_TYPES.has(poll.type)) {
+ throw new Error(`Show ${show.id}, poll ${poll.id}: invalid type "${poll.type}".`);
+ }
+ for (const opt of poll.options ?? []) {
+ if (typeof opt.id !== 'number' || typeof opt.label !== 'string') {
+ throw new Error(`Show ${show.id}, poll ${poll.id}: each option needs a numeric "id" and a string "label".`);
+ }
+ }
+ }
+ }
+
+ return shows as RawShow[];
+}
+
+export function createStaticStore(filePath: string): ShowStore {
+ const shows = parseShows(filePath);
+ const byId = new Map(shows.map((s) => [s.id, s]));
+
+ return {
+ readOnly: true,
+
+ async getShows(): Promise {
+ return shows.map((s) => ({ id: s.id, name: s.name, poll_count: (s.polls ?? []).length }));
+ },
+
+ async getShowPolls(showId: number): Promise {
+ const show = byId.get(showId);
+ if (!show) return null;
+ const polls: StoredPoll[] = (show.polls ?? []).map((p, pi) => ({
+ id: p.id,
+ question: p.question,
+ type: p.type,
+ sort_order: pi,
+ options: (p.options ?? []).map((o, oi): StoredOption => ({
+ id: o.id,
+ label: o.label,
+ direction: o.direction ?? null,
+ sort_order: oi,
+ })),
+ }));
+ return { show: { id: show.id, name: show.name }, polls };
+ },
+
+ createShow: readonlyWrite,
+ updateShow: readonlyWrite,
+ deleteShow: readonlyWrite,
+ createPoll: readonlyWrite,
+ updatePoll: readonlyWrite,
+ deletePoll: readonlyWrite,
+ reorderPolls: readonlyWrite,
+ };
+}
diff --git a/examples/pub-sub-live-voting/javascript/server/tsconfig.json b/examples/pub-sub-live-voting/javascript/server/tsconfig.json
new file mode 100644
index 0000000000..2cb7b6f25c
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/server/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "lib": ["ES2022"],
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/examples/pub-sub-live-voting/javascript/src/admin.ts b/examples/pub-sub-live-voting/javascript/src/admin.ts
new file mode 100644
index 0000000000..59965423cc
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/admin.ts
@@ -0,0 +1,986 @@
+import * as Ably from 'ably';
+import { createAblyClient, getChannel } from './shared/ably';
+import { renderChart } from './shared/chart';
+import { displayName } from './shared/clientId';
+import { showError } from './shared/errors';
+import { generateSessionId } from './shared/sessionId';
+import { IS_SANDBOX } from './config';
+import {
+ VOTE_ANNOTATION_TYPE,
+ SUGGESTION_ANNOTATION_TYPE,
+ SESSION_KEY,
+ AdminState,
+ PollMessage,
+ PollType,
+ ControlMessage,
+ PollOption,
+ PollDefinition,
+ ControllerKind,
+ SLOTS_BY_KIND,
+ isControllerKind,
+} from './shared/types';
+import { slotGlyph } from './shared/controller';
+
+// The operator console. It drives the show — it's the only role that *publishes
+// poll messages*. Voters and the presenter only react to them. This view talks
+// to the backend (the shows API + token auth), so it's not part of the
+// in-browser sandbox demo; clone the repo and run the server to use it.
+
+const VIEW_HTML = `
+
`;
+ }
+ initTabs();
+ initManage();
+ initShowRunner();
+
+ const saved = loadSavedShowState();
+ if (saved) {
+ activateShowTab();
+ try {
+ await connectToShow(saved.showId, {
+ sessionId: saved.sessionId,
+ initialState: saved.state,
+ pollIndex: saved.currentPollIndex,
+ });
+ } catch (err: any) {
+ showError(`Failed to resume show: ${err.message || err}`);
+ clearSavedShowState();
+ }
+ }
+}
+
+function activateShowTab() {
+ const tabManage = $('#tab-manage');
+ const tabShow = $('#tab-show');
+ tabShow.classList.add('active');
+ tabShow.setAttribute('aria-selected', 'true');
+ tabManage.classList.remove('active');
+ tabManage.setAttribute('aria-selected', 'false');
+}
+
+export function mount() {
+ document.body.dataset.view = 'admin';
+ document.body.dataset.state = 'manage';
+
+ // The admin console needs the backend (shows API + token auth), which the
+ // in-browser sandbox doesn't have. Clone the repo and run the server to use
+ // it; the live docs demo only exercises the voter and presenter.
+ if (IS_SANDBOX) {
+ document.body.innerHTML = `
+
+
+
Admin runs against the backend
+
The operator console drives the show and talks to the shows API and token-auth
+ endpoint, so it isn't part of the in-browser demo. Clone the repo and run the server
+ (see the README) to use it.
+
+
`;
+ return;
+ }
+
+ document.body.innerHTML = VIEW_HTML;
+ init();
+}
diff --git a/examples/pub-sub-live-voting/javascript/src/config.ts b/examples/pub-sub-live-voting/javascript/src/config.ts
new file mode 100644
index 0000000000..a8d05a14be
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/config.ts
@@ -0,0 +1,22 @@
+// Configuration and sandbox detection.
+//
+// In the hosted docs sandbox the build replaces `import.meta.env.VITE_ABLY_KEY`
+// and `import.meta.env.VITE_NAME` with real string literals, so the demo runs
+// entirely in the browser with no backend. In a normal clone these are
+// undefined, and the app talks to the bundled server instead (see ../../server).
+//
+// Only reference env vars that the docs build injects (VITE_ABLY_KEY,
+// VITE_NAME). Other `import.meta.env` lookups would survive into the sandbox
+// bundle and break it — the server-backed config (auth URL, API base) is wired
+// through same-origin paths and a Vite dev proxy instead (see vite.config.ts).
+
+export const SANDBOX_ABLY_KEY = import.meta.env.VITE_ABLY_KEY as string | undefined;
+
+// A shared channel seed. In the sandbox both preview panes get the same
+// injected value, so the voter and presenter land on the same channel without
+// exchanging a session id. In a clone this is undefined and the session id
+// comes from the URL (`?s=...`) instead.
+export const SANDBOX_CHANNEL_SEED = import.meta.env.VITE_NAME as string | undefined;
+
+/** True only inside the hosted docs sandbox (a raw key was injected). */
+export const IS_SANDBOX = Boolean(SANDBOX_ABLY_KEY);
diff --git a/examples/pub-sub-live-voting/javascript/src/demo-bootstrap.ts b/examples/pub-sub-live-voting/javascript/src/demo-bootstrap.ts
new file mode 100644
index 0000000000..7ee4bab9df
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/demo-bootstrap.ts
@@ -0,0 +1,102 @@
+import * as Ably from 'ably';
+import { SANDBOX_ABLY_KEY, SANDBOX_CHANNEL_SEED } from './config';
+import { votingChannelName } from './shared/ably';
+import demoShows from './demo-shows.json';
+import { PollMessage, PollOption, VOTE_ANNOTATION_TYPE } from './shared/types';
+
+// ─── SANDBOX ONLY ───────────────────────────────────────────────────────────
+// None of this is part of the real app. In a real show the admin console
+// starts the show and a live audience supplies the votes. The hosted docs
+// demo has neither, so this module fakes a "host": it publishes one poll and
+// seeds a handful of votes from throwaway clients, purely so the presenter
+// pane is alive and expressive the moment the page loads. The real voter pane
+// then adds its own live votes on top.
+//
+// Clone the repo and run the server (see the README) for the genuine flow —
+// you'll never use this file there.
+
+// Throwaway clients that each cast one vote. Distinct clientIds matter: votes
+// are `unique` annotations, deduped per clientId, so N distinct voters are what
+// give the heatmap N counts to spread across the options.
+const SEED_VOTERS = 6;
+
+const channelName = () => votingChannelName(SANDBOX_CHANNEL_SEED!);
+
+// Auto-start the d-pad poll — the most expressive presenter screen (a heatmap
+// with a leader badge), as opposed to a plain bar chart or the suggest stage.
+function pickDpadPoll(): PollMessage | null {
+ const show = (demoShows as { shows?: any[] }).shows?.[0];
+ const polls: any[] = show?.polls ?? [];
+ const dpad = polls.find((p) => p.type === 'dpad') ?? polls[0];
+ if (!dpad) return null;
+ return {
+ pollId: dpad.id,
+ question: dpad.question,
+ type: dpad.type,
+ // Option ids are strings on the wire (the server stringifies them); match that.
+ options: (dpad.options ?? []).map((o: any): PollOption => ({
+ id: String(o.id),
+ label: o.label,
+ direction: o.direction,
+ })),
+ };
+}
+
+export async function runDemoHost() {
+ const poll = pickDpadPoll();
+ if (!poll) return;
+
+ const host = new Ably.Realtime({ key: SANDBOX_ABLY_KEY, clientId: 'demo-host' });
+ const channel = host.channels.get(channelName(), { modes: ['publish', 'subscribe'] });
+
+ // Publishing returns void, so read the serial back from the echoed message —
+ // the seed votes are annotations and need the poll's serial to attach to.
+ channel.subscribe('poll', (msg: Ably.Message) => {
+ const data = msg.data as PollMessage;
+ if (msg.serial && data.pollId === poll.pollId) {
+ channel.unsubscribe('poll');
+ seedVotes(poll, msg.serial);
+ }
+ });
+
+ try {
+ await channel.publish('poll', poll);
+ } catch {
+ /* best-effort demo seeding; ignore failures */
+ }
+}
+
+function seedVotes(poll: PollMessage, serial: string) {
+ // Stagger them so the presenter shows a trickle of vote bubbles rather than
+ // one synchronised burst.
+ for (let i = 0; i < SEED_VOTERS; i++) {
+ setTimeout(() => castSeedVote(poll, serial), 600 + i * 350);
+ }
+}
+
+function weightedPick(options: PollOption[]): PollOption {
+ // Bias toward earlier options so a clear leader emerges on the heatmap.
+ const weights = options.map((_, i) => options.length - i);
+ const total = weights.reduce((a, b) => a + b, 0);
+ let r = Math.random() * total;
+ for (let i = 0; i < options.length; i++) {
+ r -= weights[i];
+ if (r <= 0) return options[i];
+ }
+ return options[0];
+}
+
+function castSeedVote(poll: PollMessage, serial: string) {
+ if (poll.options.length === 0) return;
+ const opt = weightedPick(poll.options);
+ const suffix = Math.random().toString(36).slice(2, 6);
+ // `Guest-xxxx` so the presenter's displayName() shows "Guest" on the bubble.
+ const bot = new Ably.Realtime({ key: SANDBOX_ABLY_KEY, clientId: `Guest-${suffix}` });
+ const channel = bot.channels.get(channelName(), { modes: ['annotation_publish'] });
+ channel.annotations
+ .publish(serial, { type: VOTE_ANNOTATION_TYPE, name: opt.id })
+ .catch(() => {})
+ // The vote persists in the summary after the bot leaves (annotations aren't
+ // presence), so we can disconnect to free the connection.
+ .finally(() => setTimeout(() => bot.close(), 2000));
+}
diff --git a/examples/pub-sub-live-voting/javascript/src/demo-shows.json b/examples/pub-sub-live-voting/javascript/src/demo-shows.json
new file mode 100644
index 0000000000..ce877c4447
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/demo-shows.json
@@ -0,0 +1,38 @@
+{
+ "shows": [
+ {
+ "id": 1,
+ "name": "Ably Live Voting demo",
+ "polls": [
+ {
+ "id": 1,
+ "question": "Which real-time feature do you reach for most?",
+ "type": "list",
+ "options": [
+ { "id": 101, "label": "Pub/Sub channels" },
+ { "id": 102, "label": "Presence" },
+ { "id": 103, "label": "Message history" },
+ { "id": 104, "label": "Push notifications" }
+ ]
+ },
+ {
+ "id": 2,
+ "question": "Rate this demo with the d-pad",
+ "type": "dpad",
+ "options": [
+ { "id": 201, "label": "Love it", "direction": "up" },
+ { "id": 202, "label": "It's neat", "direction": "right" },
+ { "id": 203, "label": "Meh", "direction": "down" },
+ { "id": 204, "label": "Confused", "direction": "left" }
+ ]
+ },
+ {
+ "id": 3,
+ "question": "What should we build with Ably next?",
+ "type": "suggest",
+ "options": []
+ }
+ ]
+ }
+ ]
+}
diff --git a/examples/pub-sub-live-voting/javascript/src/presenter.ts b/examples/pub-sub-live-voting/javascript/src/presenter.ts
new file mode 100644
index 0000000000..37b80ca728
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/presenter.ts
@@ -0,0 +1,316 @@
+import * as Ably from 'ably';
+import QRCode from 'qrcode';
+import { createAblyClient, getChannel } from './shared/ably';
+import { renderChart } from './shared/chart';
+import { renderController } from './shared/controller';
+import { displayName } from './shared/clientId';
+import { isValidSessionId } from './shared/sessionId';
+import { IS_SANDBOX, SANDBOX_CHANNEL_SEED } from './config';
+import {
+ VOTE_ANNOTATION_TYPE,
+ SUGGESTION_ANNOTATION_TYPE,
+ SESSION_KEY,
+ PresenterState,
+ PollMessage,
+ PollType,
+ ControlMessage,
+ PollOption,
+ isControllerKind,
+} from './shared/types';
+
+// The big-screen view. The presenter is the consumer that wants *individual*
+// annotation events, not just the summary: every vote and every suggestion
+// becomes a floating bubble. That's a much higher-volume stream than the
+// summary the voters get — which is exactly why annotations let each audience
+// subscribe to the slice it needs.
+
+const VIEW_HTML = `
+
Ably Live Voting
+
+
+
+
+
That session ID doesn't look right
+
Please double-check the URL.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Scan to join the show →
+
+
+
+
+
+
+
That's all, folks!
+
Thanks for taking part.
+
+
+
+
+
+
+`;
+
+let channel: Ably.RealtimeChannel | null = null;
+let currentPollOptions: PollOption[] = [];
+let currentPollType: PollType = 'list';
+let currentPollSerial: string | null = null;
+let currentPollId: number | null = null;
+let currentSummary: Ably.SummaryUniqueValues | null = null;
+let suggestListener: ((a: Ably.Annotation) => void) | null = null;
+let voteListener: ((a: Ably.Annotation) => void) | null = null;
+let acceptingSuggestions = false;
+
+const $ = (sel: string) => document.querySelector(sel) as HTMLElement;
+
+function setState(newState: PresenterState) {
+ document.body.dataset.state = newState;
+}
+
+function handleMessage(msg: Ably.Message) {
+ if (msg.name === 'poll') {
+ const data = msg.data as PollMessage;
+ const summary = msg.annotations?.summary?.[VOTE_ANNOTATION_TYPE] as Ably.SummaryUniqueValues | undefined;
+
+ if (msg.serial !== currentPollSerial) {
+ currentPollOptions = data.options;
+ currentPollType = data.type ?? 'list';
+ currentPollSerial = msg.serial!;
+ currentPollId = data.pollId;
+ currentSummary = summary ?? null;
+
+ if (currentPollType === 'suggest') {
+ unsubscribeSuggestions();
+ unsubscribeVotes();
+ $('#suggest-question').textContent = data.question;
+ $('#bubble-layer').innerHTML = '';
+ subscribeSuggestions();
+ setState('suggesting');
+ } else {
+ unsubscribeSuggestions();
+ $('#question').textContent = data.question;
+ renderResults();
+ setState('live');
+ subscribeVotes();
+ }
+ } else if (summary && currentPollType !== 'suggest') {
+ currentSummary = summary;
+ renderResults();
+ }
+ } else if (msg.name === 'control') {
+ const data = msg.data as ControlMessage;
+ if (data.action === 'end') {
+ unsubscribeSuggestions();
+ unsubscribeVotes();
+ setState('ended');
+ return;
+ }
+ if (data.action === 'reset') {
+ unsubscribeSuggestions();
+ unsubscribeVotes();
+ currentPollSerial = null;
+ currentPollId = null;
+ currentPollOptions = [];
+ currentSummary = null;
+ if (data.voterUrl) showQR(data.voterUrl);
+ return;
+ }
+ if (data.action === 'clear-suggestions') {
+ $('#bubble-layer').innerHTML = '';
+ return;
+ }
+ if (data.pollId === currentPollId) {
+ if (data.action === 'close') {
+ if (currentPollType === 'suggest') {
+ acceptingSuggestions = false;
+ } else {
+ setState('results');
+ }
+ } else if (data.action === 'show-qr') {
+ showQR(data.voterUrl!);
+ }
+ }
+ }
+}
+
+async function showQR(voterUrl: string) {
+ const canvas = $('#qr-canvas') as HTMLCanvasElement;
+ await QRCode.toCanvas(canvas, voterUrl, { width: 1000, margin: 2 });
+ // qrcode pins an inline 1000px width/height on the canvas, which would
+ // override our stylesheet sizing. Clear it so the CSS clamp() wins while
+ // the 1000px bitmap stays for crisp rendering.
+ canvas.style.width = '';
+ canvas.style.height = '';
+ setState('show-qr');
+}
+
+function renderResults() {
+ const chart = $('#chart');
+ if (isControllerKind(currentPollType)) {
+ // The d-pad heatmap: each slot fills proportionally to its share of the
+ // leader, with a star badge on the winner — all driven by the summary.
+ chart.classList.add('presenter-controller');
+ renderController(currentPollType, chart, currentPollOptions, currentSummary, {
+ showHeatmap: true,
+ showLeaderBadge: true,
+ display: 'percent',
+ });
+ } else {
+ chart.classList.remove('presenter-controller', 'controller', 'controller-dpad');
+ renderChart(chart, currentPollOptions, currentSummary, 'percent');
+ }
+}
+
+function subscribeSuggestions() {
+ if (!channel || suggestListener) return;
+ acceptingSuggestions = true;
+ const handler = (annotation: Ably.Annotation) => {
+ if (!acceptingSuggestions) return;
+ if (annotation.action === 'annotation.delete') return;
+ const text = annotation.name;
+ const cid = annotation.clientId;
+ if (!text || !cid) return;
+ spawnBubble($('#bubble-layer'), text, displayName(cid), { baseMs: 16000, decayPerAlive: 300, minMs: 3000 });
+ };
+ suggestListener = handler;
+ channel.annotations.subscribe(SUGGESTION_ANNOTATION_TYPE, handler);
+}
+
+function unsubscribeSuggestions() {
+ acceptingSuggestions = false;
+ if (channel && suggestListener) {
+ channel.annotations.unsubscribe(SUGGESTION_ANNOTATION_TYPE, suggestListener);
+ }
+ suggestListener = null;
+}
+
+function subscribeVotes() {
+ if (!channel || voteListener) return;
+ // One bubble per individual vote annotation. This is the high-volume stream
+ // the summary spares the voters from; the presenter opts in via
+ // annotation_subscribe.
+ const handler = (annotation: Ably.Annotation) => {
+ if (annotation.action === 'annotation.delete') return;
+ const optionId = annotation.name;
+ const cid = annotation.clientId;
+ if (!optionId || !cid) return;
+ const opt = currentPollOptions.find((o) => o.id === optionId);
+ if (!opt) return;
+ spawnBubble($('#vote-bubble-layer'), opt.label, displayName(cid), { baseMs: 1500, decayPerAlive: 0, minMs: 1500 });
+ };
+ voteListener = handler;
+ channel.annotations.subscribe(VOTE_ANNOTATION_TYPE, handler);
+}
+
+function unsubscribeVotes() {
+ if (channel && voteListener) {
+ channel.annotations.unsubscribe(VOTE_ANNOTATION_TYPE, voteListener);
+ }
+ voteListener = null;
+}
+
+interface BubbleLifetime {
+ baseMs: number;
+ decayPerAlive: number;
+ minMs: number;
+}
+
+function spawnBubble(layer: HTMLElement, text: string, attribution: string, lifetime: BubbleLifetime) {
+ const el = document.createElement('div');
+ el.className = 'bubble';
+ el.innerHTML = ``;
+ el.querySelector('.bubble-text')!.textContent = text;
+ el.querySelector('.bubble-attribution')!.textContent = attribution;
+
+ const { x, y } = pickBubblePosition();
+ el.style.left = `${x}%`;
+ el.style.top = `${y}%`;
+
+ const alive = layer.children.length;
+ const lifetimeMs = Math.max(lifetime.minMs, lifetime.baseMs - alive * lifetime.decayPerAlive);
+ el.style.setProperty('--lifetime', `${lifetimeMs}ms`);
+ el.addEventListener('animationend', () => el.remove(), { once: true });
+
+ layer.appendChild(el);
+}
+
+function pickBubblePosition(): { x: number; y: number } {
+ // Polar around the centre, avoiding the question text in the middle.
+ const angle = Math.random() * Math.PI * 2;
+ const r = 25 + Math.random() * 20; // 25–45% of half-viewport
+ const x = 50 + Math.cos(angle) * r;
+ const y = 50 + Math.sin(angle) * r;
+ return { x, y };
+}
+
+function connect(sessionId: string) {
+ const clientId = `presenter-${crypto.randomUUID().slice(0, 8)}`;
+ const ably = createAblyClient(clientId, sessionId, 'presenter');
+ channel = getChannel(ably, sessionId, 'presenter');
+ channel.subscribe(handleMessage);
+}
+
+function init() {
+ // Sandbox: join the shared demo channel and wait for the auto-started poll
+ // (published by demo-bootstrap). No QR — the voter pane is already connected.
+ if (IS_SANDBOX) {
+ document.body.dataset.sandbox = 'true';
+ $('#show-qr-heading').textContent = 'Connecting…';
+ setState('show-qr');
+ connect(SANDBOX_CHANNEL_SEED!);
+ return;
+ }
+
+ const sessionId = new URLSearchParams(window.location.search).get(SESSION_KEY);
+ if (!sessionId || !isValidSessionId(sessionId)) {
+ setState('invalid-session');
+ return;
+ }
+
+ connect(sessionId);
+
+ // Carry the presenter's querystring (the session) straight into the voter
+ // URL — admin builds the presenter link with the same querystring, so they
+ // stay in sync.
+ const voterUrl = `${window.location.origin}/${window.location.search}`;
+ showQR(voterUrl);
+
+ // Manual-entry fallback: the short URL displayed under the QR for people
+ // without a working camera. It already carries just the session, so it
+ // matches the QR target.
+ const qrLink = $('#presenter-qr-url') as HTMLAnchorElement;
+ qrLink.textContent = `${window.location.host}/?${SESSION_KEY}=${sessionId}`;
+ qrLink.href = voterUrl;
+}
+
+export function mount() {
+ document.body.dataset.view = 'presenter';
+ document.body.dataset.state = 'show-qr';
+ document.body.innerHTML = VIEW_HTML;
+ init();
+}
diff --git a/examples/pub-sub-live-voting/javascript/src/script.ts b/examples/pub-sub-live-voting/javascript/src/script.ts
new file mode 100644
index 0000000000..ff71259394
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/script.ts
@@ -0,0 +1,25 @@
+// Single entry point. The app has three roles — voter, presenter, admin —
+// selected by the `?role=` query param. Splitting one app this way (rather
+// than three HTML pages) lets the docs sandbox show two of the roles side by
+// side in separate preview panes, each loaded with a different `?role=`.
+import { mount as mountVoter } from './voter';
+import { mount as mountPresenter } from './presenter';
+import { mount as mountAdmin } from './admin';
+import { runDemoHost } from './demo-bootstrap';
+import { IS_SANDBOX } from './config';
+
+const params = new URLSearchParams(window.location.search);
+const role = params.get('role') ?? 'voter';
+
+if (role === 'presenter') {
+ mountPresenter();
+ // Sandbox only: `?demo=1` also makes this pane the host that auto-starts the
+ // show, since there's no admin running. See demo-bootstrap.ts.
+ if (IS_SANDBOX && params.get('demo') === '1') {
+ runDemoHost();
+ }
+} else if (role === 'admin') {
+ mountAdmin();
+} else {
+ mountVoter();
+}
diff --git a/examples/pub-sub-live-voting/javascript/src/shared/ably.ts b/examples/pub-sub-live-voting/javascript/src/shared/ably.ts
new file mode 100644
index 0000000000..9025e550f4
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/shared/ably.ts
@@ -0,0 +1,86 @@
+import * as Ably from 'ably';
+import { Role } from './types';
+import { showError, clearError } from './errors';
+import { IS_SANDBOX, SANDBOX_ABLY_KEY } from '../config';
+
+export function createAblyClient(
+ clientId: string,
+ sessionId: string,
+ role: Role,
+ extraParams?: Record,
+): Ably.Realtime {
+ // ── Authentication ──
+ //
+ // Real app: the client never sees an API key. It calls the server's `/auth`
+ // endpoint, which returns a short-lived JWT scoped to exactly this role's
+ // capabilities — a voter token can publish annotations but cannot publish
+ // messages or read other voters' raw annotations (see server/src/server.ts).
+ // This is the pattern you should use in production.
+ //
+ // Hosted docs sandbox ONLY: there is no server to call, so we fall back to a
+ // raw API key embedded in the page. Do not do this with a real app, it's used here
+ // purely so the live demo can run without a backend.
+ const client = new Ably.Realtime(
+ IS_SANDBOX
+ ? { key: SANDBOX_ABLY_KEY, clientId }
+ : {
+ authUrl: '/auth',
+ authParams: { clientId, sessionId, role, ...extraParams },
+ clientId,
+ },
+ );
+
+ client.connection.on('failed', (change) => {
+ const reason = change.reason;
+ const detail = reason?.cause?.message || reason?.message || 'unknown error';
+ showError(`Connection failed: ${detail}`);
+ });
+
+ client.connection.on('connected', () => {
+ clearError();
+ });
+
+ return client;
+}
+
+// Channel modes are the client's declared intent, enforced by Ably. They're
+// the second layer of least-privilege after the token capabilities: a voter
+// attaches with annotation_publish only, so it can cast votes but never
+// receive the (much higher-volume) individual annotation stream the presenter
+// consumes, nor publish poll messages.
+const MODES_BY_ROLE: Record = {
+ // Admin publishes poll/control messages and subscribes for echo + summaries.
+ // Also subscribes to per-annotation events to render the live suggest list.
+ admin: ['publish', 'subscribe', 'annotation_subscribe'],
+ // Voter only publishes annotations (votes, suggestions) and subscribes for
+ // poll/control messages.
+ voter: ['subscribe', 'annotation_publish'],
+ // Presenter is read-only on both messages and per-annotation events.
+ presenter: ['subscribe', 'annotation_subscribe'],
+};
+
+// The channel a session runs on. Always namespaced under `voting:` so the
+// channel rules this app needs, such as Message annotations and serverside batching, can
+// be configured against the `voting` namespace. In the sandbox `sessionId` is the
+// injected channel name; in the real app it's the admin-generated session id.
+export function votingChannelName(sessionId: string): string {
+ return `voting:${sessionId}`;
+}
+
+export function getChannel(client: Ably.Realtime, sessionId: string, role: Role): Ably.RealtimeChannel {
+ // `rewind: 1` means a client that attaches mid-poll immediately receives the
+ // current poll message (and its latest annotation summary), so late joiners
+ // and page refreshes catch up without the admin re-publishing.
+ const channel = client.channels.get(votingChannelName(sessionId), {
+ params: { rewind: '1' },
+ modes: MODES_BY_ROLE[role],
+ });
+
+ channel.on('failed', (change) => {
+ const reason = change.reason;
+ const detail = reason?.cause?.message || reason?.message || 'unknown error';
+ showError(`Channel error: ${detail}`);
+ });
+
+ return channel;
+}
diff --git a/examples/pub-sub-live-voting/javascript/src/shared/chart.ts b/examples/pub-sub-live-voting/javascript/src/shared/chart.ts
new file mode 100644
index 0000000000..e26e31235d
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/shared/chart.ts
@@ -0,0 +1,60 @@
+import * as Ably from 'ably';
+import { PollOption } from './types';
+import { CountDisplay, formatCount } from './format';
+
+const COLORS = ['#ff5416', '#1a1a1a', '#6b6b6b', '#ffa07a', '#c9c5bd', '#ff7b47'];
+
+// Renders a horizontal bar per option from an annotation summary. The summary
+// is Ably's server-side rollup of every `vote:unique.v1` annotation on the
+// poll message — `summary[optionId].total` is the live vote count for that
+// option, computed for us, not by tallying events on the client.
+export function renderChart(
+ container: HTMLElement,
+ options: PollOption[],
+ summary: Ably.SummaryUniqueValues | null,
+ display: CountDisplay = 'count',
+): void {
+ const totalVotes = options.reduce(
+ (sum, opt) => sum + (summary?.[opt.id]?.total ?? 0),
+ 0,
+ );
+
+ // Reuse existing rows if structure matches, otherwise rebuild
+ const existingRows = container.querySelectorAll('.chart-row');
+ if (existingRows.length !== options.length) {
+ container.innerHTML = '';
+ for (let i = 0; i < options.length; i++) {
+ const opt = options[i];
+ const count = summary?.[opt.id]?.total ?? 0;
+ const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0;
+
+ const row = document.createElement('div');
+ row.className = 'chart-row';
+ row.dataset.optionId = opt.id;
+ row.innerHTML = `
+ ${opt.label}
+