Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions examples/pub-sub-live-voting/javascript/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
*.local
161 changes: 161 additions & 0 deletions examples/pub-sub-live-voting/javascript/README.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions examples/pub-sub-live-voting/javascript/data/demo-shows.json
Original file line number Diff line number Diff line change
@@ -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": []
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -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'));
Original file line number Diff line number Diff line change
@@ -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'));
18 changes: 18 additions & 0 deletions examples/pub-sub-live-voting/javascript/database/seed.sql
Original file line number Diff line number Diff line change
@@ -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);
24 changes: 24 additions & 0 deletions examples/pub-sub-live-voting/javascript/database/setup.sql
Original file line number Diff line number Diff line change
@@ -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'
))
);
19 changes: 19 additions & 0 deletions examples/pub-sub-live-voting/javascript/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="src/styles.css" />
<title>Ably Live Voting</title>
</head>

<body>
<!-- script.ts reads ?role= and renders the voter, presenter, or admin view. -->
<script type="module" src="src/script.ts"></script>
</body>

</html>
11 changes: 11 additions & 0 deletions examples/pub-sub-live-voting/javascript/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
14 changes: 14 additions & 0 deletions examples/pub-sub-live-voting/javascript/server/.env.example
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions examples/pub-sub-live-voting/javascript/server/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading