项目概述
从零构建一个实时投票系统:用户可以创建投票、提交选择,仪表盘通过 Server-Sent Events 即时显示结果,无需轮询。
架构与数据模型
Hono 负责 HTTP 路由与校验,PollService 保存投票和去重记录,SSE Hub 管理连接并推送快照。投票、选项和投票者记录分离,防止重复计票。
实施阶段
先搭建 Hono 与类型定义,再实现创建和投票校验;随后用正确的 text/event-stream 头、心跳和清理逻辑实现 SSE;前端用 EventSource 渲染条形图;最后为失败路径和重连编写测试,并说明多实例扩展的限制。
验证与限制
测试覆盖重复投票、非法选项、断线重连和慢客户端;预期两个标签页同步更新。内存状态在重启后丢失,多实例需要 Redis Pub/Sub;生产还需认证、限流和持久化。
代码示例
mkdir polling-dashboard && cd polling-dashboard
pnpm init
pnpm add hono @hono/node-server nanoid
pnpm add -D typescript tsx vitest @types/node
npx tsc --init
import { serve } from '@hono/node-server';
import { createApp } from './app';
const port = Number(process.env.PORT ?? 3000);
serve({ fetch: createApp().fetch, port });
type CreatePollInput = { question: string; options: string[] };
function parseCreatePoll(body: unknown): CreatePollInput {
if (typeof body !== 'object' || body === null) throw new HttpError(400, 'invalid body');
const { question, options } = body as Record<string, unknown>;
if (typeof question !== 'string' || question.trim().length < 3 || question.length > 200) {
throw new HttpError(422, 'question must be 3-200 characters');
}
if (!Array.isArray(options) || options.length < 2 || options.length > 6) {
throw new HttpError(422, 'provide 2-6 options');
}
const normalized = options.map((option) => {
if (typeof option !== 'string' || option.trim().length === 0 || option.length > 120) {
throw new HttpError(422, 'each option must be 1-120 characters');
}
return option.trim();
});
if (new Set(normalized).size !== normalized.length) {
throw new HttpError(422, 'options must be unique');
}
return { question: question.trim(), options: normalized };
}
vote(pollId: string, optionId: string, voterKey: string): VoteResult {
const poll = this.polls.get(pollId);
if (!poll) return { ok: false, error: 'poll not found' };
const option = poll.options.find((item) => item.id === optionId);
if (!option) return { ok: false, error: 'option not found' };
const key = `${pollId}:${voterKey}`;
if (this.voters.has(key)) return { ok: false, error: 'already voted' };
this.voters.set(key, optionId);
option.votes += 1;
poll.version += 1;
return { ok: true, poll };
}
import { streamSSE } from 'hono/streaming';
app.get('/api/polls/:id/stream', (c) => {
const pollId = c.req.param('id');
const poll = polls.get(pollId);
if (!poll) return c.json({ error: 'not found' }, 404);
return streamSSE(c, async (stream) => {
const client = {
send: (event: string, data: unknown, id?: string) =>
stream.writeSSE({ event, data: JSON.stringify(data), id }),
};
hub.subscribe(pollId, client);
stream.onAbort(() => hub.unsubscribe(pollId, client));
await client.send('snapshot', poll, String(poll.version));
while (true) {
await stream.sleep(15_000);
await client.send('ping', { at: Date.now() });
}
});
});
class SSEHub {
private clients = new Map<string, Set<Client>>();
subscribe(pollId: string, client: Client) {
if (!this.clients.has(pollId)) this.clients.set(pollId, new Set());
this.clients.get(pollId)!.add(client);
}
unsubscribe(pollId: string, client: Client) {
const set = this.clients.get(pollId);
set?.delete(client);
if (set?.size === 0) this.clients.delete(pollId);
}
async broadcast(pollId: string, event: string, data: unknown, id?: string) {
const set = this.clients.get(pollId);
if (!set) return;
await Promise.allSettled([...set].map(async (client) => {
try {
await client.send(event, data, id);
} catch {
this.unsubscribe(pollId, client);
}
}));
}
}
function render(poll) {
questionEl.textContent = poll.question;
list.replaceChildren(...poll.options.map((option) => {
const row = document.createElement('li');
const label = document.createElement('span');
label.textContent = option.text;
const count = document.createElement('strong');
count.textContent = String(option.votes);
row.append(label, count);
return row;
}));
}
const created = await app.request('/api/polls', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ question: 'Lunch?', options: ['Pizza', 'Salad'] }),
});
expect(created.status).toBe(201);
const poll = await created.json();
const vote = await app.request(`/api/polls/${poll.id}/vote`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ optionId: poll.options[0].id, voterKey: 'test-voter' }),
});
expect(vote.status).toBe(200);
CREATE TABLE votes (
poll_id text NOT NULL,
voter_key text NOT NULL,
option_id text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (poll_id, voter_key)
);