Olá! Hoje quero compartilhar com você uma nova série de artigos no Blog da LuzTech, que vai se chamar Stargazed.
E do quê se trata esta série? Excelente pergunta! A ideia é compartilhar repositórios interessantes, criativos e/ou úteis que encontrei nas minhas andanças pelo GitHub, e que podem ser úteis para você também.
O nome é uma alusão à dar uma Estrela (Star) em um repositório no GitHub, e também à ideia de olhar para o céu estrelado (Stargazing) e se inspirar com o que encontramos por lá.
A ideia da série não é mostrar repositórios ultra-mega produtivos, mas sim trazer algo interessante e que, se for possível, te dê uma pontinha a mais de conhecimento.
Bom, introduções de lado, vamos para o que interessa!
Sumário
Sumário
Contêineres
Vamos começar com os contêineres. Essa tecnologia revolucionou a forma de desenvolver e implantar aplicações, e hoje é um requisito fundamental para um desenvolvedor moderno.
Contêineres permitem empacotar uma solução e tudo o que ela precisa em uma imagem imutável, portátil e (se bem feita) leve.
Como assim?
Bom, suponha que você tenha uma API em Node.js que recebe um vídeo por um endpoint e precisa convertê-lo para um formato compacto para uma análise de IA posterior (afinal ninguém merece gastar um monte de tokens com um vídeo em AVI de 5 segundos e 10GB).
E, para chamar o ffmpeg para fazer a conversão, você use o famoso exec do Node.js:
import { exec } from 'child_process';
// Suponha que inputPath e outputPath estejam devidamente sanitizados e validados
export default async function convertToSmallVideoFormat(inputPath, outputPath) {
const process = exec(
`ffmpeg -i "${inputPath}" -c:v libx264 -preset fast -crf 28 "${outputPath}"`
);
await new Promise((resolve, reject) => {
process.on("exit", code => {
if (!code) {
resolve();
} else {
reject(new Error(`ffmpeg exited with code ${code}`));
}
});
})
}
Na tua máquina vai funcionar, já que o FFMpeg vai estar instalado e disponível no PATH. Mas… e se tu precisar rodar em uma plataforma diferente, como Azure Functions, um Web App ou em uma VM Windows?
Daí tu vai precisar instalar o FFMpeg na máquina, o que não é possível e muitas plataformas serverless, como Azure Functions.
Então, qual a solução? Contêineres! Tu pode criar uma imagem com o Node.js e o FFMpeg instalados, e rodar a tua API em qualquer lugar que aceite contêineres, que é o caso da maioria das plataformas modernas, incluindo as que citei acima.
Um container para esta solução seria algo assim:
FROM node:24.15.0
WORKDIR /app
RUN apt-get update && apt-get install -y ffmpeg
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["npm", "start"]
Depois é só rodar um docker build . e a imagem estará prontinha para rodar em praticamente qualquer lugar.
O código acima foi simplificado para fins didáticos. Para uso em produção, sempre procure as boas práticas da linguagem e/ou framework que você estiver usando.
O repositório
Com este breve resumo de contêineres, vem o ponto principal do artigo: o container2wasm. Quando descobri ele fiquei pasmo, pois, teoricamente, ele permite rodar qualquer coisa diretamente do navegador, sem instalar nada.
Mas claro, nem tudo são flores. Mesmo com um computador bem potente, o contêiner vai rodar com uma performance péssima, então não dá pra dizer que a experiência do usuário vai ser a melhor do mundo (se tiver como o usuário ter uma experiência, pra começo de conversa).
Além disso, a não ser que tenha um servidor de proxy de rede, as chamadas HTTP de contêiner vão usar a função fetch do navegador, que traz o infame CORS pra jogada, bloqueando qualquer requisição cross-domain sem o cabeçalho Access-Control-Allow-Origin adequado.
Mas, chega de enrolar. Como fazer um contêiner rodar no navegador? Primeiramente, precisamos instalar a ferramenta a partir das releases.
Para usuários Windows, é necessário usar o WSL ou outra forma de virtualização Linux, já que o container2wasm não tem suporte nativo para Windows. E para usuários do MacOS, existe uma fórmula no Homebrew que deixa a instalação bem mais simples.
Depois de instalado e configurado no PATH, os comandos c2w e c2w-net devem estar disponíveis no terminal.
Agora, pegue uma imagem salva localmente, pode ser uma imagem criada com docker build ou uma imagem baixada com docker pull, e rode o seguinte comando:
c2w --to-js "<imagem>:<tag>" "<pasta de saída>/"
Ao rodar a versão instalada via Homebrew, tive um erro de git clone ao fazer o build da imagem WASM. Pra resolver, adicionei esta flag: --build-arg "SOURCE_REPO=https://github.com/container2wasm/container2wasm". Não tive o mesmo problema ao clonar o repositório e fazer o build manualmente em Go.
Com isso, uma imagem WASM será gerada, e será possível adicionar o contêiner a um site. Mas… como fazer isso?
Rodando o contêiner WASM no navegador
Com os arquivos em mãos, é necessário criar um arquivo HTML que vá carregar o contêiner WASM. A pasta de exemplos do projeto contém uma boa referência. Para o caso de imagens usando a flag --to-js os exemplos de emscripten e emscripten-simple são o melhor ponto de partida.
E qual deles escolher?
A diferença entre eles é o gerenciamento de rede.
O emscripten permite que o contêiner se comunique com a internet por meio da API fetch do navegador, ou usando um WebSocket como proxy de rede (com o c2w-net, por exemplo).
Já o emscripten-simple não tem suporte a rede, e é mais simples de configurar.
Cada exemplo possui um README.md que mostra como criar uma imagem compatível e como configurar a rede, se necessário.
Todas as instruções a seguir foram retiradas do repositório e podem ficar desatualizadas. Se for seguir estes passos, confira se não houve atualizações no repositório antes de prosseguir.
Na prática: Neovim com o repositório de tipografia da LuzTech
Vamos testar o mais simples (emscripten-simple). Este é o Dockerfile que criei para abrir o Neovim diretamente na raíz do repositório de tipografia da LuzTech:
Se você olher no link do repositório, vai ver que usei a tag v2026.08.03-02.22. Isto porque, no momento em que eu estava trabalhando neste artigo, o repositório ainda não era um site.
FROM alpine:latest AS builder
RUN apk add --no-cache git
WORKDIR /workspace
RUN git clone "https://github.com/LuzTech-Development/Typography" .
RUN rm -rf .git
FROM alpine:latest AS runtime
RUN apk add --no-cache neovim
COPY --from=builder /workspace /workspace
WORKDIR /workspace
ENTRYPOINT ["nvim", "."]
Construí a imagem com o comando:
docker build --platform linux/amd64 -t nvim-demo:latest .
Eu adicionei a flag --platform linux/amd64 para forçar a construção da imagem para a arquitetura x86_64, já que o README do container2wasm recomenda esta arquitetura para diminuir as camadas de emulação.
Depois disso rodei o comando que descrevi na seção acima para gerar os arquivos em WebAssembly.
Então copiei o index.html do exemplo e adicionei o Fit Addon para o XTerm.js para o terminal ficar em tela cheia.
Ver index.html modificado
<html>
<head>
<title>container2wasm: Neovim + LuzTech Typography</title>
<link rel="stylesheet" href="./vendor/xterm.css" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<style>
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #000;
}
#terminal {
width: 100%;
height: 100dvh;
}
</style>
</head>
<body>
<div id="terminal"></div>
<script src="./load.js"></script>
<script type="module">
import './vendor/xterm.js';
import './vendor/xterm-pty.js';
import { FitAddon } from './vendor/addon-fit.mjs';
import './arg-module.js';
import initEmscriptenModule from './out.js';
const xterm = new Terminal();
const fitAddon = new FitAddon();
xterm.loadAddon(fitAddon);
xterm.open(document.getElementById('terminal'));
fitAddon.fit();
const { master, slave } = openpty();
xterm.loadAddon(master);
Module.pty = slave;
Module['mainScriptUrlOrBlob'] = new URL('./out.js', import.meta.url).href;
// "/pack/info" file needs to be provided to the filesystem to start
// the container. Refer to /cmd/init/main.go for the detail of info file.
let info = 't:' + Math.round(new Date() / 1000) + '\n';
Module['preRun'].push(mod => {
try {
FS.mkdir('/pack');
} catch (e) {}
mod.FS.writeFile('/pack/info', info);
});
var readableCallbacks = [];
Module.pty.onReadable(() => {
readableCallbacks.forEach(cb => cb());
readableCallbacks = [];
});
Module['preRun'].push(Module => {
Module['TTY'].stream_ops.poll = (stream, timeout, notifyCallback) => {
if (Module.pty.readable) {
return 1;
}
if (notifyCallback != null) {
notifyCallback.registerCleanupFunc(() => {
const i = readableCallbacks.indexOf(notifyCallback);
if (i != -1) readableCallbacks.splice(i, 1);
});
readableCallbacks.push(notifyCallback);
}
return 0;
};
});
const fitTerminal = () => fitAddon.fit();
addEventListener('resize', fitTerminal);
new ResizeObserver(fitTerminal).observe(
document.getElementById('terminal')
);
(async () => {
await initEmscriptenModule(Module);
fitTerminal();
})();
</script>
</body>
</html>As dependências são carregadas via CDN no index.html original, mas os cabeçalhos Cross-Origin-Embedder-Policy e Cross-Origin-Opener-Policy (obrigatórios para o container2wasm funcionar) podem causar inconsistência no download delas. Para evitar isso, baixei tudo para uma pasta vendor/ ao lado do index.html.
Ver script para baixar dependências localmente
#! /bin/bash
mkdir -p vendor
curl -fsSL "https://unpkg.com/@xterm/xterm@5.5.0/css/xterm.css" -o vendor/xterm.css
curl -fsSL "https://unpkg.com/xterm@5.3.0/lib/xterm.js" -o vendor/xterm.js
curl -fsSL "https://unpkg.com/xterm-pty/index.js" -o vendor/xterm-pty.js
curl -fsSL "https://esm.sh/@xterm/addon-fit@0.10.0/es2022/addon-fit.mjs" -o vendor/addon-fit.mjsPor fim, para ver como ficou, rodei o comando serve de Vercel com o seguinte arquivo serve.json na raíz:
{
"headers": [{
"source": "**/*",
"headers": [
{
"key": "Cross-Origin-Embedder-Policy",
"value": "require-corp"
}, {
"key": "Cross-Origin-Opener-Policy",
"value": "same-origin"
}
]
}]
}
Estes cabeçalhos são obrigatórios para o sistema funcionar, porém eles causam outros problemas, como, no caso do blog, impedir que o Gravatar funcione ou causar inconsistências com o download de dependências de CDNs. Se for usar este repositório em algum projeto tenha isto em mente.
E assim funcionou! Olha como ficou:


Se quiser testar: Clique aqui
Demora BASTANTE para o contêiner iniciar, não é erro na implementação. Em um Macbook Air M5 demorou cerca 3m30s para aparecer a primeira tela do Neovim.
Conclusão
Tá, mas e o exemplo com rede? Estamos já na conclusão?
Sim! Eu me propus a explorar bem o repositório, mas o exemplo com rede consumiu bem mais tempo e energia do que eu tinha me proposto a usar. Então decidi focar no exemplo mais simples para esta postagem.
Dito isso, o que podemos concluir deste repositório?
Bom, é uma ideia bem interessante. Deixar contêineres mais portáteis do que já são. No entanto, sendo bem honesto, ainda há muitos desafios a serem superados para que isso seja realmente prático no dia a dia.
Primeiro eu diria que a experiência de uso não é muito boa, já que há várias variações e flags relevantes para configurar. No Docker, por exemplo, é docker build e docker run.
Em segundo lugar, apesar de ser compreensível, o tempo de compilação é bem longo, mesmo para imagens pequenas, o que torna iterações lentas e frustrantes.
E por fim eu diria que o uso ainda não faz sentido no cenário atual. Contêineres já são portáteis, tempos de carregamento são vitais no navegador e muitas ferramentas (Vite+, por exemplo) estão otimizando para experiência de desenvolvimento.
De qualquer forma, é um projeto legal para explorar e aprender sobre a arquitetura de contêineres e para ver até onde o navegador aguenta.