██████╗ ███████╗████████╗██████╗ ██████╗ ██╗ ██╗ █████╗ ██╗ ██╗██╗ ████████╗
██╔══██╗██╔════╝╚══██╔══╝██╔══██╗██╔═══██╗██║ ██║██╔══██╗██║ ██║██║ ╚══██╔══╝
██████╔╝█████╗ ██║ ██████╔╝██║ ██║██║ ██║███████║██║ ██║██║ ██║
██╔══██╗██╔══╝ ██║ ██╔══██╗██║ ██║╚██╗ ██╔╝██╔══██║██║ ██║██║ ██║
██║ ██║███████╗ ██║ ██║ ██║╚██████╔╝ ╚████╔╝ ██║ ██║╚██████╔╝███████╗██║
╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝
Emulação retro no browser. Sem instalação. Sem cadastro. Só jogar.
RetroVault é um catálogo interativo de jogos clássicos com emulação direto no browser. Interface estilo terminal CRT com efeito scanlines, tema cyberpunk verde/âmbar, suporte a 14 plataformas e centenas de títulos selecionados a dedo. Acessa, escolhe uma plataforma, escolhe o jogo e começa a jogar. É isso.
| Plataforma | Lançamento | Era | Emulador |
|---|---|---|---|
| Nintendo Entertainment System | 1983 | 80s | fceumm |
| Super Nintendo Entertainment System | 1990 | 90s | snes9x |
| Sega Master System | 1985 | 80s | genesis_plus_gx |
| Sega Mega Drive / Genesis | 1988 | 80s | genesis_plus_gx |
| Nintendo Game Boy | 1989 | 80s | gambatte |
| Nintendo Game Boy Color | 1998 | 90s | gambatte |
| Nintendo Game Boy Advance | 2001 | 00s | mgba |
| Sony PlayStation 1 | 1994 | 90s | pcsx_rearmed |
| Nintendo 64 | 1996 | 90s | mupen64plus |
| Arcade Classics (FBNeo) | — | 80s/90s | fbneo |
| Capcom Play System 1 | 1988 | 80s | fbneo |
| Capcom Play System 2 | 1993 | 90s | fbneo |
| Capcom Play System 3 | 1996 | 90s | fbneo |
| SNK Neo Geo MVS | 1990 | 90s | fbneo |
Cerca de 290 jogos catalogados, filtráveis por década e plataforma.
Mouse / Touch
- Navegue pelo carousel de plataformas com as setas
- Clique numa plataforma para ver os jogos
- Clique num jogo para iniciar
Teclado
| Tecla | Ação |
|---|---|
← → |
Navegar entre plataformas |
↑ ↓ |
Navegar entre jogos |
Enter |
Iniciar jogo |
P |
Focar busca de plataforma |
J |
Focar busca de jogo |
Idiomas: PT-BR / EN — botão no canto superior direito.
Frontend → HTML + CSS + JavaScript (sem frameworks, sem build)
Emulação → EmulatorJS via CDN (cdn.emulatorjs.org) + SRI
Hospedagem → AWS S3 (static website hosting)
CDN / HTTPS → Cloudflare (proxy + cache + certificado)
Cache → Cache Rules na Cloudflare (ROMs com TTL de 1 mês)
Contador de visitas → Cloudflare Worker + Workers KV
CI/CD → GitHub Actions (test → deploy → smoke) + gitleaks
Segurança → Bucket policy + WAF (geo + hotlink) + SRI + Security Headers
O diagrama abaixo mostra o fluxo completo de uma requisição — do usuário até os dados.
Usuário abre emu.dellabeneta.com
│
▼
┌───────────────────────────────────────────────────┐
│ Cloudflare │
│ cache · HTTPS · WAF (geo+hotlink) · headers │
└────────────────────┬──────────────────────────────┘
│
┌───────────┴────────────┐
│ │
│ GET /api/visits │ GET /*
│ │ (html, css, js, roms, assets)
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ Cloudflare Worker │ │ AWS S3 │
│ retrovault-visits │ │ bucket restrito │
│ │ │ │
│ incrementa count │ │ index.html │
│ retorna JSON │ │ data.js │
│ { count: N } │ │ script.js │
└──────────┬──────────┘ │ style.css │
│ │ favicon.ico │
│ │ assets/ · roms/ │
▼ └──────────────────────┘
┌─────────────────────┐
│ Workers KV │
│ VISITORS │
│ { count: N } │
└─────────────────────┘
GitHub ──push──► Actions: test ──► deploy ──► smoke
Como o contador funciona na prática:
- Usuário abre o site — o browser carrega o
script.jsdo S3 via Cloudflare - O JS faz
fetch('/api/visits')— a Cloudflare roteia para o Worker - O Worker lê o valor atual do KV, incrementa, grava e retorna
{ count: N } - O número aparece no rodapé ao lado do ícone de olho
O workflow .github/workflows/deploy-emu.yml executa 3 jobs em sequência:
| Job | O que faz | Condição |
|---|---|---|
test |
Varredura de segredos (gitleaks) + testes unitários + validação do catálogo contra o S3 |
Sempre ao push em main |
deploy |
Envia os 5 arquivos ao S3 + purge seletivo na Cloudflare | Só se test passar |
smoke |
Valida a borda ao vivo, contra produção | Só após deploy |
O purge de cache é seletivo — invalida apenas index.html, data.js, script.js, style.css e favicon.ico, preservando o cache de ROMs e assets na Cloudflare.
O purge é feito por chamada direta à API da Cloudflare, não por action de terceiro, e o job reprova se a resposta não trouxer "success": true. Purge que falha em silêncio é pior que purge nenhum: o deploy fica verde servindo conteúdo velho.
As credenciais AWS e Cloudflare são gerenciadas via GitHub Secrets, sem nenhuma informação sensível no código.
Detalhe do smoke test: o site é restrito ao Brasil, e runners do GitHub são estrangeiros. As requisições do CI atravessam o bloqueio por um header secreto (SMOKE_KEY). A localização do runner, em vez de obstáculo, virou parte do teste — uma requisição sem o header tem que receber 403, o que valida o bloqueio geográfico de fora do país a cada deploy.
tests/
├── infra/
│ ├── security.sh # borda: geo, hotlink, cache, headers, HTTPS, favicon
│ └── catalog.js # toda ROM do catálogo existe no S3 (disco como fallback)
└── unit/
├── i18n.js # traduções PT/EN sincronizadas
└── platforms.js # estrutura obrigatória do PLATFORMS[]
Para rodar localmente:
bash tests/infra/security.sh
node tests/infra/catalog.js
node tests/unit/i18n.js
node tests/unit/platforms.jsO security.sh detecta o país de saída da execução e adapta as asserções: rodando do Brasil ele pula o teste de bloqueio geográfico, porque dali o teste não seria conclusivo. Testes que não podem concluir são pulados com o motivo impresso, nunca dados como aprovados.
emu.dellabeneta.com/
├── index.html # estrutura da interface
├── style.css # tema CRT cyberpunk (verde #00ff88 + âmbar #ffaa00)
├── data.js # catálogo PLATFORMS[] e textos das plataformas
├── script.js # lógica, i18n, integração EmulatorJS
├── favicon.ico
├── sync-s3.sh # utilitário: sincroniza roms/ e assets/ com o S3
├── assets/ # imagens das plataformas (S3, fora do git)
├── roms/ # arquivos de jogo (S3, fora do git)
├── docs/
│ └── backlog.md # plano de ação e decisões em aberto
├── tests/
│ ├── infra/
│ │ ├── security.sh
│ │ └── catalog.js
│ └── unit/
│ ├── i18n.js
│ └── platforms.js
└── .github/
└── workflows/
└── deploy-emu.yml
Não precisa de build. Só precisa servir os arquivos estáticos — qualquer servidor HTTP funciona.
# Python
python3 -m http.server 8080
# Node
npx serve .
# VS Code
# extensão Live Server → botão "Go Live"As ROMs precisam estar na pasta
roms/localmente para o emulador funcionar.
ROMs e assets não são versionados no git — vivem diretamente no S3. Para sincronizar após adicionar novos arquivos:
bash sync-s3.shRequer AWS CLI configurado localmente com as credenciais corretas.
As ROMs são o volume de dados do projeto. Quando cada partida vira um download novo na origem, o egress do S3 aparece na fatura rápido.
O diagnóstico: o plano Free da Cloudflare só cacheia extensões conhecidas por
padrão. .zip e .bin entravam nessa lista; .nes, .sfc, .gba e companhia
não — retornavam cf-cache-status: DYNAMIC e sem nenhum Cache-Control.
Ou seja: toda partida ia até o S3, e nada ficava no navegador do jogador.
A correção foi uma Cache Rule em /roms/*:
| Configuração | Valor |
|---|---|
| Cache eligibility | Eligible for cache |
| Edge TTL | 1 mês (override origin) |
| Browser TTL | 1 mês (override origin) |
ROM é conteúdo imutável, então TTL longo é seguro. O Browser TTL resolve o
Cache-Control que a origem não enviava — reabrir o mesmo jogo passou a não
gerar requisição alguma.
Medido nos headers depois da mudança: .nes saiu de DYNAMIC para HIT, com
Cache-Control: max-age=2678400.
No mesmo passo, o favicon.ico caiu de 1,4 MB para 14,7 KB — era maior que
qualquer ROM de NES do catálogo e carregava em toda visita. O smoke test tem
guarda para os dois casos, para que nenhum regrida em silêncio.
| Camada | Implementação |
|---|---|
| Acesso ao S3 | Bucket policy restrita aos IPs da Cloudflare — requisições diretas ao endpoint S3 retornam 403 Forbidden. Todo o tráfego é obrigado a passar pela borda. |
| Bloqueio geográfico | WAF Custom Rule libera apenas o Brasil. Duas exceções deliberadas: bots verificados (cf.client.bot, para preservar indexação no Google e preview de link em WhatsApp/Discord) e um header secreto usado pelo CI. |
| Hotlink protection | WAF Custom Rule bloqueia /roms/* quando o Referer existe e não é do próprio domínio nem da CDN do EmulatorJS. Impede que outro site embuta o catálogo consumindo a banda daqui. |
| Integridade do EmulatorJS | SRI (Subresource Integrity) com hash SHA-256 na tag de carregamento — o browser recusa executar o script se o arquivo do CDN externo for adulterado. |
| Security headers | X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Strict-Transport-Security, Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Resource-Policy: same-origin e Permissions-Policy negando geolocalização, microfone, câmera e pagamento. |
| Segredos no repositório | gitleaks roda no CI antes do deploy, sobre o histórico do git. Segredo commitado reprova o pipeline e o release não sai. |
Limites conhecidos. Documentados de propósito, porque controle sem limite declarado é marketing:
- Bloqueio geográfico depende de geolocalização por IP e hotlink protection
depende do header
Referer. VPN contorna o primeiro;Referrer-Policy: no-referrercontorna o segundo. São redução de superfície e de custo, não barreira contra atacante determinado. - Regra de WAF nova não afeta objeto que já está no cache da borda. Foi preciso um purge para o hotlink protection valer nas ROMs já cacheadas.
- O endpoint do Formspree fica exposto no JS do cliente. É inevitável em front-end puro; a mitigação é a proteção anti-spam do próprio Formspree e a restrição de domínio.
Vale explicitar o que este projeto não precisa proteger, porque é isso que define o desenho:
- Não há autenticação. Não existe conta, login ou sessão. Sem credencial de usuário, não há credencial de usuário para vazar.
- Não há base de usuários. O único estado persistido é o contador de visitas no Workers KV: um inteiro, sem qualquer vínculo com pessoa.
- O único dado pessoal é o formulário de feedback, que pede nome e comentário. Ele não é armazenado na infraestrutura do projeto — vai direto ao Formspree, que é o responsável pelo tratamento.
O que sobra a proteger, então, é a infraestrutura e o custo: impedir acesso direto ao bucket, impedir que terceiros sirvam o conteúdo às nossas custas, e garantir que o que o browser executa é exatamente o que foi publicado.
O rodapé exibe o número total de visitas ao site em tempo real, representado por um ícone de olho.
A solução é serverless e totalmente dentro da infraestrutura Cloudflare:
| Componente | Papel |
|---|---|
Cloudflare Worker (retrovault-visits) |
Função JavaScript que roda na borda da rede Cloudflare, sem servidor dedicado. Intercepta GET /api/visits, incrementa o contador e retorna JSON. |
Workers KV (VISITORS) |
Banco chave-valor distribuído globalmente. Armazena uma única chave: count. Leitura e escrita em qualquer edge da Cloudflare sem latência de região. |
| Rota | emu.dellabeneta.com/api/visits aponta exclusivamente para o Worker — o resto do tráfego segue para o S3 normalmente. |
Não há servidor, não há banco de dados relacional, não há dependência externa. O Worker é análogo a uma AWS Lambda com API Gateway — mas roda no edge, com cold start zero e integrado nativamente ao mesmo proxy que já serve o site.
O rodapé do site tem um botão Feedback que abre um modal para qualquer visitante deixar uma opinião, sugestão ou reportar um bug. O formulário pede nome completo e um comentário em texto livre (máximo 300 caracteres).
Os envios chegam por email via Formspree — sem backend, sem banco de dados. O endpoint do Formspree fica exposto no JS do cliente por design (é inevitável em front-end puro), mas o formulário tem proteção anti-spam nativa do Formspree e está restrito ao domínio emu.dellabeneta.com.
[RetroVault] — feito por @dellabeneta