Urna Eletrônica com TypeScript e Vite: Máquina de Estados, Testes e API sem Banco

Os bastidores técnicos da urna eletrônica do meu portfólio: build multipágina com Vite, TypeScript strict, a lógica da cabine como uma máquina de estados pura testada com Vitest, sons com Web Audio, modo áudio com Web Speech e uma API PHP que guarda cada eleição em JSON com lock e gravação atômica.

A urna eletrônica do meu portfólio nasceu como uma releitura da urna brasileira que fiz para o Grupo Positivo, com o objetivo de ensinar crianças a votar. Na reestruturação, ela ganhou um visual de desenho animado dos anos 30 (estilo rubber hose, inspirado em Cuphead) e, por baixo, uma arquitetura nova: front-end em TypeScript compilado pelo Vite, testes com Vitest e uma API em PHP que não usa banco de dados. Este artigo mostra as decisões técnicas por trás do projeto. O código é aberto e está no GitHub.

O Node.js como ferramenta de build

No navegador, a urna não tem nenhuma dependência de runtime: nada de React, Vue ou jQuery, só TypeScript e CSS puro. O Node.js entra como ferramenta de desenvolvimento: todas as dependências do package.json são devDependencies e cada script resolve uma etapa do ciclo:

{
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "lint": "prettier --check . && tsc --noEmit"
  },
  "devDependencies": {
    "prettier": "^3.9.10",
    "typescript": "^7.0.2",
    "vite": "^8.3.4",
    "vitest": "^5.0.4"
  },
  "engines": { "node": ">=20" }
}

Repare que o build roda o tsc --noEmit antes do Vite. O Vite apenas remove os tipos ao compilar, sem checá-los; quem garante que o código está correto é o compilador do TypeScript, que aqui só verifica e não gera arquivos.

Vite com várias páginas e proxy para o PHP

O site tem quatro páginas independentes (início, cabine de votação, resultados e administração). Em vez de uma SPA com roteador, cada HTML é uma entrada do build, e o Vite gera um pacote JavaScript enxuto para cada uma. Durante o npm run dev, as chamadas para /api e /uploads são repassadas ao PHP que roda no XAMPP, então front e back funcionam juntos com hot reload:

import { resolve } from 'node:path'
import { defineConfig } from 'vitest/config'

// Onde o PHP está rodando durante o `npm run dev` (XAMPP ou `php -S`).
const php = process.env.PHP_SERVER ?? 'http://localhost/urna'

export default defineConfig({
  root: 'web',
  base: './',
  build: {
    outDir: resolve(import.meta.dirname, 'public'),
    emptyOutDir: true,
    rollupOptions: {
      input: {
        index: resolve(import.meta.dirname, 'web/index.html'),
        votar: resolve(import.meta.dirname, 'web/votar.html'),
        resultados: resolve(import.meta.dirname, 'web/resultados.html'),
        admin: resolve(import.meta.dirname, 'web/admin.html'),
      },
    },
  },
  server: {
    proxy: {
      '/api': { target: php, changeOrigin: true },
      '/uploads': { target: php, changeOrigin: true },
    },
  },
  test: {
    root: import.meta.dirname,
    include: ['tests/**/*.test.ts'],
  },
})

Dois detalhes do Node moderno aparecem aqui: import.meta.dirname substitui o antigo __dirname em módulos ES, e o mesmo arquivo configura o build e os testes, porque o Vitest reaproveita a configuração do Vite.

TypeScript no modo mais rigoroso

O tsconfig.json liga o strict e mais algumas opções que pegam erros comuns antes de o código rodar:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noFallthroughCasesInSwitch": true,
    "verbatimModuleSyntax": true,
    "noEmit": true
  }
}

A cabine como uma máquina de estados pura

Essa é a decisão mais importante do projeto. Na versão antiga, a lógica da votação ficava espalhada pelos eventos de clique, e um clique durante a animação de troca de tela era avaliado contra o estado anterior, o que permitia gravar um voto nulo como válido. Na versão nova, toda a regra da urna está em uma função reduce(estado, evento) que não toca no DOM:

export type Phase = 'voting' | 'saving' | 'done' | 'error'

export type BoothEvent =
  | { type: 'digit'; digit: string }
  | { type: 'correct' }
  | { type: 'blank' }
  | { type: 'confirm' }
  | { type: 'saved' }
  | { type: 'failed'; message: string }
  | { type: 'retry' }
  | { type: 'reset' }

export function reduce(ballot: Ballot, state: BoothState, event: BoothEvent): BoothState {
  const office = ballot.offices[state.step]

  switch (event.type) {
    case 'digit': {
      if (state.phase !== 'voting' || !office || state.blank) return state
      if (!/^\d$/.test(event.digit) || state.digits.length >= office.digits) return state
      return { ...state, digits: state.digits + event.digit }
    }

    case 'blank': {
      // Como na urna real, BRANCO só vale com o visor vazio.
      if (state.phase !== 'voting' || state.blank || state.digits !== '') return state
      return { ...state, blank: true }
    }

    case 'confirm': {
      if (state.phase !== 'voting' || !office) return state
      const complete = state.digits.length === office.digits
      if (!state.blank && !complete) return state

      const choices = { ...state.choices, [office.id]: state.blank ? null : state.digits }
      const last = state.step === ballot.offices.length - 1

      return last
        ? { ...state, phase: 'saving', choices }
        : { ...state, step: state.step + 1, digits: '', blank: false, choices }
    }

    // ...correct, saved, failed, retry e reset seguem a mesma ideia
  }
}

A união discriminada BoothEvent faz o TypeScript reclamar se algum tipo de evento ficar sem tratamento. E há um truque simples: quando o evento não tem efeito, reduce devolve o mesmo objeto. A interface compara as referências para saber se a tecla fez algo e só então redesenha a tela e toca o som:

private dispatch(event: BoothEvent): void {
  const previous = this.state
  const next = reduce(this.ballot, previous, event)
  if (next === previous) return

  this.state = next
  this.render(previous)
  this.effects(previous, next, event)
}

Mouse, toque, teclado físico e até a "colinha" com a lista de candidatos passam pelo mesmo dispatch. Não existe outro caminho para mudar o estado da cabine.

Testando as regras da urna com Vitest

Como reduce é uma função pura, testar as regras da urna real é só aplicar uma sequência de eventos e conferir o resultado, sem navegador e sem DOM simulado:

import { describe, expect, it } from 'vitest'
import { type BoothEvent, initialState, reduce, selection } from '../web/src/booth/machine'

const run = (...events: BoothEvent[]) =>
  events.reduce((state, event) => reduce(ballot, state, event), initialState())

const digits = (value: string): BoothEvent[] => [...value].map((digit) => ({ type: 'digit', digit }))
const blank: BoothEvent = { type: 'blank' }

describe('voto em branco', () => {
  it('só é aceito com o visor vazio', () => {
    expect(run(...digits('1'), blank).blank).toBe(false)
    expect(run(blank).blank).toBe(true)
  })
})

it('identifica candidato, número parcial e voto nulo', () => {
  expect(selection(ballot, run(...digits('12'))).kind).toBe('partial')
  expect(selection(ballot, run(...digits('9999'))).kind).toBe('null')
})

Interface sem innerHTML

Nomes de candidatos e partidos vêm do painel administrativo, ou seja, são dados digitados por alguém. Para eliminar o risco de XSS, a interface nunca monta HTML a partir de strings. Um helper h() cria os elementos, e todo texto vira nó de texto:

export function h<K extends keyof HTMLElementTagNameMap>(
  tag: K, attrs?: Attrs | null, ...children: Child[]
): HTMLElementTagNameMap[K] {
  const el = document.createElement(tag)

  for (const [key, value] of Object.entries(attrs ?? {})) {
    if (value === undefined || value === null || value === false) continue

    if (key.startsWith('on') && typeof value === 'function') {
      el.addEventListener(key.slice(2).toLowerCase(), value as EventListener)
    } else {
      el.setAttribute(key, value === true ? '' : String(value))
    }
  }

  el.append(...flatten(children))
  return el
}

// uso
h('p', { class: 'candidate' }, candidate.name)

O tipo genérico HTMLElementTagNameMap[K] faz h('img') devolver um HTMLImageElement e h('button') um HTMLButtonElement, com autocompletar correto no editor.

Sons e voz com APIs nativas do navegador

A urna não carrega nenhum arquivo de áudio. Os bipes são sintetizados com a Web Audio API: um oscilador de onda quadrada e um ganho que decai rápido, o que lembra o som eletrônico da urna real. O famoso "pi-pi-pi-piii" do fim da votação são cinco tons agendados no tempo do próprio AudioContext:

function tone(frequency: number, start: number, duration: number, volume = 0.05): void {
  const ctx = audio()
  if (!ctx) return

  const at = ctx.currentTime + start
  const osc = ctx.createOscillator()
  const gain = ctx.createGain()

  osc.type = 'square'
  osc.frequency.value = frequency
  gain.gain.setValueAtTime(volume, at)
  gain.gain.exponentialRampToValueAtTime(0.0001, at + duration)

  osc.connect(gain).connect(ctx.destination)
  osc.start(at)
  osc.stop(at + duration + 0.02)
}

// O "pi-pi-pi-piii" de fim de votação.
function finish(): void {
  ;[0, 0.11, 0.22, 0.33].forEach((start) => tone(1900, start, 0.07))
  tone(1900, 0.44, 0.55)
}

O modo áudio imita o fone de ouvido da urna real: com a Web Speech API, a tela é lida em voz alta em português. Cada anúncio também atualiza uma região aria-live, então leitores de tela recebem as mesmas informações mesmo com o modo áudio desligado:

announce(text: string, { interrupt = true } = {}): void {
  if (region) region.textContent = text
  if (!enabled) return

  if (interrupt) speechSynthesis.cancel()
  const utterance = new SpeechSynthesisUtterance(text)
  utterance.lang = 'pt-BR'
  utterance.rate = 1.05
  speechSynthesis.speak(utterance)
}

Uma API sem banco de dados

Cada eleição é um único arquivo JSON. Isso simplifica a hospedagem, mas exige cuidado com acessos simultâneos: se dois eleitores votarem ao mesmo tempo, um voto não pode apagar o outro. A classe JsonStore resolve isso com flock exclusivo durante todo o ciclo de ler, alterar e gravar, e com gravação atômica (escreve em um arquivo temporário e depois o renomeia por cima do original):

public function update(string $key, callable $mutate): array
{
    return $this->withLock($key, LOCK_EX, function () use ($key, $mutate): array {
        $data = $mutate($this->load($key));
        $this->write($key, $data);

        return $data;
    });
}

private function write(string $key, array $data): void
{
    $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
    $tmp = $this->path($key) . '.' . bin2hex(random_bytes(4)) . '.tmp';

    if (file_put_contents($tmp, $json . "\n") === false || !rename($tmp, $this->path($key))) {
        @unlink($tmp);
        throw new RuntimeException("Falha ao gravar o documento {$key}.");
    }
}

Como o rename é atômico no mesmo sistema de arquivos, quem lê a eleição vê o arquivo antigo inteiro ou o novo inteiro, nunca um JSON pela metade.

O servidor decide o voto

O front-end envia apenas o número digitado para cada cargo, ou null para voto em branco. Quem decide se aquele número é de um candidato ou é voto nulo é a API, que também valida a quantidade de dígitos e exige todos os cargos. Assim, uma requisição manipulada não consegue registrar um voto que a urna não aceitaria.

Cada cédula é guardada no RDV (Registro Digital do Voto), sem horário e sem identificação, e inserida em uma posição aleatória da lista, para que a ordem não revele quem votou em quem:

array_splice($e['rdv'], random_int(0, count($e['rdv'])), 0, [$ballot]);

Na apuração, os totais são recontados a partir do RDV e comparados com o placar acumulado. Se não baterem, o arquivo foi alterado por fora da API e o boletim mostra "Inconsistência detectada". O boletim ainda traz um hash SHA-256 do resultado, que permite conferir se ele não foi modificado depois de impresso.

Integração contínua

A cada push, um workflow do GitHub Actions roda duas tarefas em paralelo: no Node 22, executa npm ci, Prettier, checagem de tipos, os testes do Vitest e o build; no PHP 8.2, faz o lint de todos os arquivos e roda os testes da API. Um commit que quebre qualquer regra da urna não passa despercebido.

npm ci
npm run lint   # prettier --check . && tsc --noEmit
npm test       # vitest run
npm run build  # tsc --noEmit && vite build

Conclusão

A urna é um projeto pequeno, mas que exigiu regras exatas: o voto não pode se perder, não pode mudar de categoria no meio do caminho e não pode ser adulterado sem que isso apareça na apuração. Isolar a lógica em uma máquina de estados pura, tipar tudo com rigor e deixar a validação final com o servidor foi o que tornou essas garantias simples de testar. E o Node.js, mesmo sem rodar em produção, sustenta todo o fluxo de desenvolvimento, do servidor com hot reload aos testes e ao build final.

repositório no GitHub

Tecnologia:
js

Deixe seu Comentário!

Dúvida, sugestão ou só um "valeu!" — tudo é bem-vindo por aqui.

Comentário
Nome
Email
lock Seu e-mail não será publicado.