← 中文资源

简体中文版本

用 Hono 与 SSE 构建实时投票仪表盘

完整的 Hono 实时投票应用,包含投票去重、SSE 重连、测试和扩展限制。

阅读英文原文

项目概述

从零构建一个实时投票系统:用户可以创建投票、提交选择,仪表盘通过 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)
);