Skip to content
Merged
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
133 changes: 133 additions & 0 deletions examples/pub-sub-live-voting/javascript/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# 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.

There's a lot of different ways you can implement this using Ably. This example
shows one possible pattern, using
[**annotations**](/docs/messages/annotations) for votes. A poll
is a regular Ably message, and 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 are never stored in a database. They live entirely as annotations on the channel, and Ably does the aggregation. Your own server's job is narrow: it authenticates clients and it hands out the poll definitions. Anything expensive and latency-sensitive is done by Ably.

## Why annotations?

The simplest way you could imagine implementing a live voting system with Ably would be to have each vote be a message. The presenter would subscribe to the channel and keep a running tally in memory, and re-render when needed. This works for a demo, but has drawbacks. Everyone gets the same set of messages, so every voter sees everyone's vote unnecessarily; and nothing constrains one client to only have one vote without custom deduplication logic in any client that wants to count votes, which could get increasingly expensive as the set of voters grows. There's no server-side aggregation, so a phone that joins late, or a presenter view that refreshes, has missed the votes that already happened and has to reconstruct the tally from history. (And imagine if you do have a hundred thousand voters, you'd have to paginate through tens of thousands of pages of history to get the vote count).

[LiveObjects](/docs/liveobjects) is another possibility, which solves some of these problems. You could model the tally as a [`LiveCounter`](https://ably.com/docs/liveobjects/counter) per option; a synchronised, conflict-free counter on the channel. It's a lovely primitive. But there's no built-in one-vote-per-client constraint: any client can call `increment` as often as it likes. To stop ballot-stuffing you'd need a separate [`LiveMap`](https://ably.com/docs/liveobjects/map) of `clientId → vote` and check it before incrementing, but there's no atomic check-and-increment across the two, so a double-counting is still possible. And there's no fine-grained capabilities; any client with the ability to increment one livecounter can arbitrarily mutate the whole state.

So, [annotations](/docs/messages/annotations), which have three big advantages:

- Summaries and raw events are two separate streams, and different clients want different ones. A voter's phone only needs the *summary*: a single compact, rolled-up object, with aggregated information on all the annotations that have been contributed. The exact information in the summary depends on the annotation's [aggregation type](/docs/messages/annotations#aggregation), and there are several choices. With very popular polls, each voter only gets a periodically-rolled-up summary update, not one event per vote. But the presenter screen does want to see individual annotation events, so it can animate a bubble for each one (and label it with the voter who cast it), and it can opt into that higher-volume stream separately, with the [`ANNOTATION_SUBSCRIBE` channel mode](https://ably.com/docs/messages/annotations#individual-annotations) and an `annotations.subscribe()` listener. Same underlying data is exposed in two ways, each appropriate to the view that needs it.

- Capabilities for least-privilege. Because a vote is an annotation, a voter's [token](/docs/auth/capabilities) can grant *only* the `annotation-publish` capability on the channel (and `message-subscribe`). A voter can cast votes and do literally nothing else: it can't publish poll messages (which are 'full' messages, not annotations), and it can't subscribe to other voters' raw annotations. It can't tamper with the poll or snoop on who voted for what. The presenter gets `annotation-subscribe`; the admin gets full message publish rights. 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 person, one vote, enforced by Ably. Votes use the `unique` [aggregation type](/docs/messages/annotations#aggregation). In `unique` mode Ably keeps at most one annotation per `clientId`, and switching to another option *moves* that client's vote rather than adding a second one. As long as your auth server vets users and assigns each one a unique `clientId`, a careful choice of aggregation type gives you the vote semantics you need.

## Roles

The app has three roles.

- The *voter* is the phone in your hand: pick an option (a list, or a d-pad for something more playful), and watch the live percentages. It only ever publishes annotations and reads the summary.
- The *presenter* is the big screen. It reads the summary to draw the bar chart or d-pad heatmap (with a star badge on the current leader), and subscribes to individual annotations to pop a bubble for each vote and suggestion as it lands.
- The *admin* is the operator console, the only role that publishes poll messages. It walks through a show of polls, opens and closes voting, and (because it can both publish and subscribe) shows the organiser a live view of what's happening.

## 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
});
```

That annotation type string, `vote:unique.v1`, follows the `namespace:summarization.version` convention; `unique` is one of [five aggregation types](/docs/messages/annotations#aggregation) (`unique`, `distinct`, `multiple`, `total`, `flag`), each rolling up the same raw annotations a different way.

The channel [is attached with `rewind: 1`](/docs/channels/options/rewind), so a phone that joins (or refreshes) mid-poll immediately receives the current poll message and its latest summary. Late joiners are caught up without the admin republishing anything.

## 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()](/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 high volume 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. This 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 is a cut-down stub of the full demo, intended to be run entirely in the browser, to demonstrate the voting page. If you want to try it out yourself, the real example, with a proper auth server and the admin/voter/presenter split and token authentication, lives at [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