Vinte e cinco utilitários, zero envios
O selo na página principal da Toolbox diz 100% NO CLIENTE · NADA É ENVIADO. São cinco palavras de marketing e cerca de trinta mil linhas de consequências — porque, a partir do momento em que nos recusamos a mandar bytes para um servidor, é o navegador que tem de fazer o trabalho de sistemas.
Nesta página
Prometer que um ficheiro nunca sai do dispositivo é barato. Cumpri-lo não é. Um site de utilitários com servidor consegue calcular o hash de uma ISO de 3 GB com quatro linhas de Node e uma máquina com memória a sério. A Toolbox não tem nem uma coisa nem outra: tem um separador, uma heap que o navegador pode matar quando lhe apetecer e uma thread principal que também tem de continuar a mexer o seu cursor. Tudo o que o servidor teria feito teve de ser reconstruído dentro desse separador — compressão em fluxo, hashing incremental, gestão do ciclo de vida dos workers, um codificador de QR, um pipeline de cor com mapeamento de gama — e teve de sobreviver a ficheiros maiores do que a memória disponível para os conter.
Isto é um passeio pelas partes deste trabalho que foram genuinamente difíceis: o contrato de worker que os oito trabalhos em segundo plano partilham, a disciplina de memória por detrás do processamento de ficheiros no cliente e a pequena pilha de algoritmos que acabámos por escrever nós próprios porque as alternativas pressupunham um backend.
- 25
- utilitários, quatro categorias
- 8
- Web Workers dedicados
- 39,246
- linhas no commit de lançamento
- 0
- pedidos de rede na superfície de utilitários
A Toolbox saiu a 2026-07-25; desde então, a superfície de utilitários é verificada por grep quanto a chamadas de rede.
Um contrato RPC, oito workers
Oito utilitários fazem trabalho pesado que chegue para precisarem de uma thread própria: gzip, ZIP, conversão de imagens, hash de ficheiros, comparação de texto, Base64, o testador de expressões regulares e a extração de paletas. Oito conversas postMessage improvisadas teriam sido oito conjuntos dos mesmos erros, por isso todas assentam num único módulo de 149 linhas — um cliente RPC de Web Worker tipado, com uma superfície deliberadamente pequena.
export function createWorkerClient<
Req extends object, // NOT { id?: number } — that bound would trip
Res extends { id: number }, // TypeScript's weak-type check for request unions
>(factory: () => Worker): {
call(request: Req, opts?: { transfer?: Transferable[]; onProgress?: OnProgress }): Promise<Res>;
terminate(): void;
};O contrato inteiro, menos o corpo. Quem chama nunca escreve um id — o cliente injeta-o e encaminha as respostas por ele.
O valor está nas regras de encaminhamento. Uma resposta que traga { id, progress } é entregue ao onProgress sem resolver a promessa, por isso um arquivo de dois minutos pode reportar-se cem vezes numa só chamada. Uma resposta que traga { id, error } rejeita essa chamada. Um erro grave do worker — a thread morreu, o chunk não carregou — rejeita todas as chamadas pendentes e descarta o worker; a chamada seguinte a call() volta a criá-lo discretamente através da fábrica que lhe foi dada. Os workers também são criados de forma preguiçosa, na primeira utilização, por isso abrir o utilitário de ZIP não custa nada até largar lá um ficheiro.
Essa última propriedade dá-nos o cancelamento de graça. Não há nenhuma flag cooperativa de abortar enfiada num ciclo de compressão; o terminate() mata a thread a meio de um chunk e o pedido seguinte constrói uma nova. O testador de expressões regulares é quem mais se apoia nisto, porque um testador de regex é uma máquina de correr programas fornecidos pelo utilizador com custo ilimitado. Cada execução tem um cliente descartável e um prazo de 600 ms; quando expira, terminamos, reportamos o resultado como timeout, e um trinco settled engole a rejeição que chega depois do cadáver. Só existe um padrão em curso de cada vez — uma tecla mais recente sobrepõe-se ao que estava em fila em vez de fazer bicha — e o cancel() limpa a fila antes de terminar, porque de outra forma o trabalho em fila criaria um worker para um componente já desmontado.
Duas convenções do mesmo ficheiro envelheceram melhor do que esperávamos. Os erros atravessam a fronteira como códigos estáveis (zip:*, failed), nunca como prosa, para que a interface os traduza em seis línguas em vez de mostrar uma exceção em inglês a um leitor português. E as metades que correm no worker são tipadas à mão: o nosso tsconfig traz a lib DOM e não webworker, por isso, dentro de um worker, self é uma Window. Em vez de lutar com a configuração, cada worker escreve const scope = self as unknown as DedicatedWorkerScope e declara os dois membros que realmente usa — um truque que estreámos no propagador do Orrery e de que nunca nos arrependemos.
Memória plana, ficheiros de qualquer tamanho
As CompressionStream e DecompressionStream são a razão pela qual um utilitário de gzip pode sequer existir num separador: gzip e deflate, nativos, em fluxo, sem payload de WASM. A armadilha é o que se faz com a saída. O gesto óbvio — recolher os chunks, concatenar num Uint8Array e embrulhá-lo num Blob — duplica o pico de memória no pior momento possível.
Descomprimir 2 GB precisaria de 2 GB duas vezes. O construtor de Blob costura as partes sem esse pico.
Por isso a função de drenagem partilhada recolhe BlobPart[] e está documentada, no próprio ficheiro, como nunca as concatenando. O escritor de ZIP vai mais longe e descarrega as partes acumuladas a cada 16 MiB durante o DEFLATE, porque o armazenamento de Blobs é gerido pelo navegador e passa para disco — a heap mantém-se plana enquanto o arquivo cresce para além de tudo o que o separador poderia conter. A extração funciona no sentido inverso: o File.slice() dá acesso aleatório ao diretório central, por isso um arquivo de 4000 entradas é listado sem ler o seu grosso, e cada linha tem o seu próprio armazenamento externo em vez de uma fatia de estado partilhado do React, porque uma lista que volta a renderizar três vezes por entrada não é uma lista que se queira num telemóvel.
O progresso é limitado na origem e não no React — passos de 1% para o hashing, 80 ms para o ZIP — e é medido por uma TransformStream de identidade que conta bytes do lado da entrada do pipeline. A saída comprimida faria a barra saltar e estagnar consoante o quão comprimível fosse o ficheiro; os bytes de entrada só andam para cima.
Acima de 4 GiB o escritor de ZIP deixa de tentar. Fazer DEFLATE a um ficheiro desse tamanho rende pouco e arrisca tudo, por isso, para lá de MAX_DEFLATE_BYTES, o escritor continua a calcular o CRC-32 — com uma tabela de 256 entradas construída de forma preguiçosa, polinómio 0xEDB88320, incremental entre chunks — e emite uma entrada STORE que reutiliza o File original sem qualquer cópia. Há também um patamar mínimo de navegadores: o Firefox 111 e o 112 passam a nossa linha de suporte mas são anteriores à CompressionStream (113), por isso esses utilizadores recebem um utilitário de ZIP a funcionar que armazena em vez de comprimir, e um aviso a explicar porquê.
// TS 5.9 types the codec's writable as WritableStream<BufferSource>, which
// pipeThrough rejects against Uint8Array<ArrayBuffer> chunks even though a
// Uint8Array IS a BufferSource. Narrowing the pair is safe.gzip-core.ts. A outra metade da mesma família: o crypto.subtle quer especificamente Uint8Array<ArrayBuffer> — ArrayBufferLike não é atribuível a BufferSource.
Um SHA-256 em fluxo sem BigInt
O crypto.subtle.digest é rápido, nativo e constitucionalmente de uma só vez: recebe um buffer, não um fluxo. Para ficheiros que se possa dar ao luxo de segurar, isso está bem e é melhor do que qualquer coisa que pudéssemos escrever. O calculador de hashes tem, por isso, dois modos com uma fronteira rígida entre eles aos 256 MiB.
Abaixo da linha, guarda o ficheiro uma vez e calcula os quatro resumos à partida — SHA-1, SHA-256, SHA-384, SHA-512 —, o que transforma os selos de algoritmo da interface em meros filtros de apresentação. Passar de SHA-256 para SHA-512 volta a desenhar uma string; não volta a ler o seu ficheiro. Acima da linha, guardar em memória é exatamente o que não podemos fazer, por isso o utilitário passa para um SHA-256 incremental escrito à mão e alimentado por File.stream(), com memória plana e sem teto.
Escrever um SHA-256 é um rito de passagem; escrever um que sobreviva a revisão dá um bocadinho mais de trabalho. O nosso segue a FIPS 180-4 à letra, incluindo a parte que toda a gente erra — o comprimento da mensagem é acrescentado como um inteiro big-endian de 64 bits, e os números de JavaScript não conseguem representar um desses com exatidão. Em vez de trazer BigInt para o caminho crítico, o comprimento é transportado em duas palavras de 32 bits e incrementado com transporte. É testado por propriedades contra o crypto.subtle em todas as fronteiras de preenchimento que interessam (0, 55, 56, 63, 64 e 65 bytes — os dois casos de 56 bytes são onde as implementações ingénuas perdem um bloco) e outra vez em 1 MB + 7, para provar que o caminho em fluxo e o caminho de uma só vez concordam em entradas que atravessam limites de chunk.
Nada persiste, exceto as preferências
Ser local não é automaticamente ser privado. O localStorage é local, e um utilitário que se lembrasse da sua última palavra-passe gerada seria uma responsabilidade pior do que um que a enviasse para algum lado — pelo menos o servidor esquece. Por isso o módulo de armazenamento tem um contrato de uma linha escrito por cima do formato das chaves: apenas opções, nunca conteúdos, nunca segredos gerados.
// vaynerov:tools:<slug>:v1 — user OPTIONS only.
// Never persist user payloads or generated secrets.
// Uniform pick without modulo bias: reject the ragged tail of the range.
const limit = Math.floor(0x100000000 / alphabet.length) * alphabet.length;
// never a bare % n, never Math.randomO gerador de palavras-passe guarda os valores no estado do React — não no localStorage, não no URL, não em analítica — e tira-os do crypto.getRandomValues com amostragem por rejeição.
Todas as leituras e escritas vão embrulhadas em try/catch, porque os modos de navegação privada lançam exceção no acesso em vez de falharem com educação, e uma camada de preferências não tem nada que deitar abaixo um utilitário. O extrator de paletas dispensa a persistência por completo: a sua entrada é uma fotografia sua.
Tirar os resultados cá para fora tem o seu próprio folclore. Os URLs de objeto são revogados dez segundos depois do clique, não de forma síncrona — revogar imediatamente produz transferências silenciosas de zero bytes no Safari, que é a classe de erro mais irritante que há, porque nada dá erro. As transferências de vários ficheiros são espaçadas 250 ms, para que as heurísticas de pop-ups do navegador não comam as entradas da quarta em diante, sem qualquer espera no fim da última.
O atacante é a sua própria área de transferência
Recusar um servidor significa também recusar um higienizador do lado do servidor. A pré-visualização de markdown renderiza texto colado arbitrário diretamente na página que está a ver, a cada tecla, com a sua sessão ali mesmo ao lado — por isso está escrita como se a entrada fosse hostil, porque às vezes é (um README de um repositório que não leu é entrada não fidedigna).
Todos os blocos de texto são escapados antes da concatenação e não depois da montagem, o que elimina uma família inteira de erros em que uma passagem de escape percorre marcação que ela própria acabou de gerar. Os URLs são reverificados após uma ronda de descodificação de entidades HTML — javascript: é um URL javascript: de chapéu na cabeça — e um link que falhe a segunda verificação é despromovido a texto simples em vez de desaparecer, para que o utilizador veja o que escreveu. A própria descodificação de entidades é um ramo de último recurso do tokenizador, não um passo de pré-processamento, precisamente porque uma passagem de pré-processamento entregaria ao analisador texto que nunca validou.
Depois há o custo. Tudo o que esteja no caminho de uma tecla precisa de um orçamento, e a pré-visualização tem quatro.
| Orçamento | Limite | Porque existe |
|---|---|---|
| MAX_DELIMS | 5000 caracteres | O trabalho com delimitadores de ênfase é superlinear; contado em caracteres, não em nós |
| MAX_LINK_SCAN | 200 000 caracteres | Novas varreduras por parênteses não fechados, cobradas só quando uma varredura falha |
| MAX_TABLE_CELLS | 20 000 células | Uma folha de cálculo colada, um DOM limitado |
| MAX_BLOCK_DEPTH | 32 níveis | Citações e listas aninhadas não conseguem chegar à pilha |
Os quatro orçamentos por tecla da pré-visualização de markdown. A bateria de testes de XSS que guarda o mesmo ficheiro tem 116 vetores.
Codificadores que escrevemos nós
Dois utilitários trazem algoritmos em vez de invólucros, pela razão pouco glamorosa de as bibliotecas que teríamos usado pesarem mais do que o próprio utilitário.
O gerador de QR é um codificador ISO/IEC 18004 modelo 2 completo, cobrindo as versões 1 a 11: correção de erros Reed–Solomon sobre GF(256) com o polinómio primitivo padrão 0x11D, divisão e entrelaçamento de blocos, a colocação em ziguezague dos módulos que salta a coluna vertical de temporização na coluna 6, os oito padrões de máscara gerados e pontuados com as quatro regras de penalização exatas da especificação, informação de formato como BCH(15,5) em XOR com 0x5412, e informação de versão como BCH(18,6) da versão 7 para cima. Nada disto é negociável — um código QR quase certo lê no seu telemóvel e falha no da caixa do supermercado. Verificámos as palavras de código contra o exemplo publicado HELLO WORLD versão 1-Q, depois escrevemos um descodificador e fizemos ida e volta às versões 1–9 em três modos de codificação e nos quatro níveis de correção de erros, à capacidade exata.
O extrator de paletas é um median cut com um desvio deliberado. O median cut dos manuais divide uma caixa de cor no índice mediano dos seus compartimentos ocupados, e em fotografias isso está bem. Em logótipos é um desastre: dê-lhe uma imagem 90% branca e 10% preta e o índice mediano cai dentro da sequência plana dominante, por isso as duas metades voltam quase brancas e a paleta perde o preto por completo. O nosso encontra o canal mais largo e depois divide no maior intervalo entre compartimentos ocupados desse canal, desempatando na direção da mediana populacional. Amostra a cerca de 100×100 e reporta em OKLCH, que é também a língua do conversor de cor.
Um código QR quase certo lê no seu telemóvel e falha no da caixa do supermercado.
Vinte e cinco cartões que nunca respiram em sincronia
A página principal tinha um problema de design que é na verdade um problema de renderização: vinte e cinco cartões, quatro categorias e espaço nenhum para vinte e cinco ilustrações. Cada categoria é dona de um matiz HSL puro — ficheiros 35 92% 60%, texto 265 72% 70%, código 210 90% 62%, design 325 85% 66%, todos acima de 4,5:1 contra os nossos dois tons de fundo — que o anfitrião do utilitário define como --tool-hue no seu contentor, para que cabeçalhos, selos e a marca de água do ícone se tinjam sozinhos sem uma única condição.
A arte dos cartões são quatro dioramas em CSS 3D, um por categoria, com semente por utilitário. Não é WebGL — são transformações e gradientes — e a semente importa mais do que a geometria: um hash FNV-1a de 32 bits do slug alimenta um gerador xorshift que devolve floats unitários, por isso o Math.random e o Date.now não aparecem em lado nenhum do caminho de renderização. Pelas regras de pureza do React, um componente que lança dados enquanto renderiza é um componente que treme; um componente que faz o hash do seu próprio slug é uma função pura que calha parecer improvisada. Cada cartão recebe também um multiplicador de velocidade de ±15% e um desfasamento de fase da mesma semente, que é a razão inteira pela qual a grelha não pulsa como um coração.
Há mais dois contratos a segurar essa camada. O movimento pausa fora do ecrã — o useInView com uma margem de 240 px define data-paused, que congela as cenas através de animation-play-state em vez de as desmontar — e a inclinação dos cartões corre sobre valores de movimento do framer motion com zero setState por movimento do rato, e nunca é ativada no toque. E sob prefers-reduced-motion, a transformação estática de cada elemento é escrita como a sua própria pose de repouso composta, para que o fotograma parado se leia como um diorama deliberado em vez de uma animação partida a meio do passo. São 371 linhas de CSS para pôr quatro cenas quietas como deve ser.
O interessante na restrição de não enviar nada é que nunca deixa adiar um problema para a máquina de outra pessoa. Todos os tetos da Toolbox são reais, e preferimos nomeá-los a escondê-los.
Não há File System Access API, por isso o resultado sai por transferências em vez de ser escrito de volta no seu disco. Não há WASM, por isso vivemos dentro do que a plataforma dá — que é a razão pela qual o Firefox 111 só recebe ZIPs em STORE e pela qual um arquivo de 5 GiB é armazenado em vez de comprimido. Os ficheiros continuam a ter de caber no separador, e o separador é do navegador para reclamar. Um dia depois do lançamento apontámos oito revisores adversariais a esta mesma superfície e encontraram trinta defeitos confirmados, que documentámos por inteiro em vez de corrigirmos em silêncio.