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
}
}
noUncheckedIndexedAccess:offices[step]passa a ter o tipoOffice | undefined, o que obriga a tratar o caso de o índice não existir.verbatimModuleSyntax: imports só de tipo precisam ser escritos comoimport type, e assim o bundler sabe exatamente o que pode remover.moduleResolution: "bundler": resolve os imports do mesmo jeito que o Vite, sem exigir extensão nos caminhos.
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.
0 Comentário
Deixe seu Comentário!
Dúvida, sugestão ou só um "valeu!" — tudo é bem-vindo por aqui.