brixel
v0.7.0
Published
[beta] node_modules que quase não ocupa disco. Use brixel install no lugar do npm install: cada pacote fica guardado uma vez só no seu computador, e dez projetos com o mesmo React ocupam o espaço de um.
Maintainers
Readme
Beta. Funciona e tem teste, mas ainda rodou em pouca máquina. Use num projeto que dá pra reinstalar sem dor de cabeça. Se não gostar,
brixel ejectdesfaz tudo com um comando.
O que é isso
Todo projeto JavaScript tem uma pasta chamada node_modules. É onde ficam os pacotes que o projeto usa — código pronto, escrito por outras pessoas, que o seu projeto só aproveita. Essa pasta é grande: 300 MB, 500 MB, às vezes mais de 1 GB. Em cada projeto.
O problema é que cada projeto guarda a própria cópia de tudo. Se você tem dez projetos e todos usam o React, são dez Reacts idênticos ocupando espaço no disco.
O brixel guarda um e faz os dez apontarem para ele. Você continua vendo a pasta node_modules de sempre, com arquivos de verdade dentro — o que muda é que o espaço é cobrado uma vez.
Imagine um prédio em que cada apartamento tem a própria caixa d'água cheia: dez caixas guardando exatamente a mesma água. O brixel troca isso por uma caixa só, ligada a todos os apartamentos.
Da torneira continua saindo a mesma água, na mesma hora. O que muda é quanta água você precisa estocar.
Os dois comandos que importam
| comando | quando você usa | o que ele faz |
|---|---|---|
| brixel install | primeira vez no projeto | Instala os pacotes. Baixa da internet só o que ainda não está guardado na sua máquina. |
| brixel restore | você apagou a node_modules | Remonta a pasta inteira a partir do que já está guardado. Sem internet, sem baixar nada — 22 mil arquivos em ~4 s. |
Os outros comandos estão em Comandos.
Quanto encolhe
| situação | npm | brixel | | |---|---|---|---| | 1 projeto sozinho | 460 MB | 171 MB | −63% | | 5 projetos parecidos | 2300 MB | 478 MB | −79% | | 4 projetos bem diferentes entre si | 1725 MB | 1106 MB | −35% |
Repare na primeira linha: mesmo com um projeto só, sem nada para repetir, o disco já cai pela metade. São dois motivos que não dependem de você ter vários projetos:
- dentro do próprio
node_modulesjá existe arquivo repetido; - o brixel descarta o que ninguém usa para rodar o projeto — documentação, exemplos, mapas de código — e comprime o resto.
A partir do segundo projeto entra o ganho principal: o que se repete entre eles é guardado uma vez só. Quanto mais parecidos, mais os projetos convergem para o custo de um.
Números medidos em quatro projetos reais (Next 14, backend Node, monorepo Vite+API) — como foram medidos.
Instalação
npm install -g brixelDepois, dentro do projeto:
brixel installPrecisa do Node 22 ou mais novo.
Com o gerenciador que você já usa:
pnpm add -g brixel
yarn global add brixel
bun add -g brixelSem instalar nada:
npx brixel install
pnpm dlx brixel install
yarn dlx brixel install
bunx brixel installTanto faz com qual gerenciador você instala o brixel. O que importa é o lockfile do projeto onde ele vai rodar:
| projeto usa | arquivo | funciona? |
|---|---|---|
| npm | package-lock.json (v3) | sim |
| pnpm | pnpm-lock.yaml (9.x) | sim |
| yarn clássico (1.x) | yarn.lock | sim |
| bun | bun.lock | sim |
| bun antigo | bun.lockb | não — rode bun install --save-text-lockfile para converter |
| yarn moderno (berry, 2.x–4.x) | yarn.lock em YAML | não — o lockfile não traz o integrity do tarball, e sem ele o brixel não valida o download |
Não gostou? Um comando desfaz
brixel ejectIsso devolve um node_modules normal, do jeito que o npm faria, e apaga o que o brixel tinha criado. Depois disso o projeto funciona sem o brixel instalado e você volta a usar npm install.
Existe de propósito: você não fica preso.
Comandos
brixel install # instala os pacotes do projeto
brixel restore # remonta o node_modules apagado, sem internet
brixel stats # quanto disco você economizou
brixel gc # limpa o que não é mais usado por nenhum projeto
brixel doctor # verifica se está tudo certo
brixel approve-builds x # deixa o pacote x rodar o próprio instalador
brixel eject # volta pro node_modules normal e some com o brixelDurante o install roda uma animação ASCII de uma cidade sendo construída. Ela some sozinha em terminal não interativo, em CI, com --no-animation ou com BRIXEL_NO_ANIMATION=1.
Daqui para baixo é o detalhe de engenharia: como o brixel guarda os arquivos, por que isso é seguro e o que ele nunca faz. Se você só quer usar a ferramenta, pode parar aqui.
Como funciona
| Componente | Responsabilidade |
|---|---|
| Resolver | Lê o lockfile e produz {nome, versão, resolved, integrity, hasInstallScript} |
| Fetcher | Baixa só os tarballs que ainda não estão na store e valida o integrity |
| Store (CAS) | Armazém global em ~/.brixel/, um blob por conteúdo, indexado em sqlite |
| Manifest | .brixel/manifest.bin — msgpack de caminho → hash + modo + tamanho, mais os links de workspace |
| Linker | Materializa o node_modules com clonefile, hardlink ou cópia |
| Scripts | Roda lifecycle script dos pacotes aprovados e captura o que eles produzem |
| GC | Recalcula o conjunto vivo lendo os manifestos e apaga blobs órfãos |
| Stats | Calcula a economia real |
Fluxo do install
- lê o
package-lock.json, calcula o sha256 dele e descarta as dependências opcionais de outra plataforma (os/cpu/libc), como o npm faz — sem isso um projeto com esbuild ou Next carrega ~20 binários que nunca vão rodar - se o manifesto existente tem o mesmo
lockfileHash, pula direto para a materialização - consulta a store, baixa o que falta e valida o
integrityantes de gravar qualquer byte - extrai em memória: para cada arquivo, sha256 → grava o blob se for novo
- monta e grava o manifesto
- materializa o
node_modules - roda os lifecycle scripts aprovados e captura o que eles geraram (
fromBuild: true) - imprime a economia
Materialização
Na ordem de preferência:
copyFilecomCOPYFILE_FICLONE_FORCE— copy-on-write (APFS, Btrfs, XFS). Custa ~0 byte e o arquivo é independente.fs.link— hardlink. Custa 0 byte.copyFilenormal — fallback.
O suporte é detectado uma vez por par de dispositivos e guardado em ~/.brixel/config.json.
Cada blob guarda o próprio modo (0o644 ou 0o755, normalizado como o npm faz). Hardlink só é usado quando o modo do blob é exatamente o que a entrada precisa — assim binário executável também é deduplicado, em vez de virar cópia. Quando não bate, cai para clonefile ou cópia.
Sobre o hardlink: o arquivo no projeto e o blob na store são o mesmo inode. Editar um arquivo dentro do node_modules edita a store — e, por tabela, todos os outros projetos. Em condições normais ninguém edita node_modules, mas fica o aviso. Nunca recebem hardlink: pacotes com lifecycle script (o script escreve no diretório deles) e o brixel eject.
Monorepos
Workspaces npm (workspaces no package.json da raiz) são suportados:
- os caminhos do manifesto são relativos à raiz do projeto, não ao
node_modules/da raiz — por isso umbackend/node_modules/lodash@3convive com onode_modules/lodash@4da raiz sem colidir; - entradas
link: truedo lockfile viram symlink de verdade (node_modules/@app/api→../../backend), gravado no manifesto para orestorerecriá-lo offline; - cada
node_modulestem seu próprio.bin, e oPATHdos lifecycle scripts sobe por essa cadeia.
pnpm-workspace.yaml não é lido — o v1 só entende o lockfile do npm, workspaces incluídos.
Compressão transparente do filesystem
Deduplicação só ajuda quem tem vários projetos parecidos. Para o projeto sozinho, o que reduz de fato é a compressão do próprio filesystem: no macOS os blobs entram na store via ditto --hfsCompression, que ativa a compressão transparente do APFS/HFS+.
O arquivo continua sendo um arquivo normal — o kernel descomprime na leitura. Nenhuma ferramenta precisa saber: next build, tsc, bundler, binário nativo, tudo lê os bytes originais. E como a compressão é uma propriedade do inode, ela sobrevive ao hardlink: os dois ganhos se somam em vez de competir.
Medido no mesmo projeto Next 14:
| | npm | brixel | |---|---|---| | 1 projeto (nenhuma dedup possível) | 460 MB | 171 MB | | 2 projetos iguais | 920 MB | 171 MB |
Fora do macOS a compressão transparente não é aplicada (em Btrfs dá para conseguir o mesmo com chattr +c na store). Desligue com BRIXEL_TRANSPARENT_COMPRESSION=0.
Poda de arquivos descartáveis
Compressão tem um teto: o APFS usa zlib e chega a ~2x. O que reduz além disso é o arquivo que não precisa existir. Por padrão o Brixel descarta na origem — nunca entram na store nem no manifesto:
- source maps (
*.js.map,*.css.map) — 15% do disco de um projeto Next típico - docs, exemplos e benchmarks (
docs/,examples/,benchmark/,website/) - markdown (
README.md,CHANGELOG.md) — excetoLICENSE,NOTICEeCOPYING, que ficam por obrigação legal
Nunca são podados: package.json, qualquer coisa em bin/, .d.ts (o tsc precisa deles), fontes .ts, e diretórios de teste — o ganho deles é irrisório e o risco não é.
O install imprime quantos arquivos descartou. Para manter tudo:
brixel install --no-prune{ "brixel": { "prune": { "sourceMaps": false, "docs": true, "markdown": true } } }O perfil de poda fica gravado por pacote na store, então trocar a configuração rebaixa só o que precisa.
Compressão zstd na store
A store detecta, ao ser criada, se consegue linkar (hardlink/clonefile) do armazém para os projetos:
- consegue: grava os blobs crus. Blob comprimido não pode ser linkado — o Node leria bytes zstd. Aqui a economia vem da deduplicação, que é a maior parte dela.
- não consegue (store e projetos em dispositivos diferentes, por exemplo): grava com zstd, já que tudo viraria cópia de qualquer jeito e a compressão passa a ser o único ganho possível.
O zstd usa o nativo do node:zlib quando disponível e cai para @napi-rs/zstd quando não. Dá para forçar com BRIXEL_COMPRESSION=zstd|none. brixel doctor mostra o que foi escolhido.
Lifecycle scripts
Pacotes como esbuild, sharp e better-sqlite3 baixam ou compilam binário no postinstall. Esse binário não existe no tarball. O Brixel só roda script de pacote que você aprovou explicitamente:
{
"brixel": {
"buildDependencies": ["esbuild", "sharp", "better-sqlite3"]
}
}Ou brixel approve-builds esbuild. Pacote com script fora da lista não executa nada, e o install avisa nomeando cada um. Rodar script arbitrário de dependência transitiva por padrão é a mesma classe de risco que o Brixel existe para não introduzir.
Depois de rodar, tudo que o script gerou dentro do diretório do pacote entra na store e vira entrada de manifesto com fromBuild: true. Efeito colateral bom: o binário compilado é deduplicado como qualquer outro blob — cinco projetos com o mesmo esbuild guardam um binário só. E é isso que faz o restore funcionar offline e sem re-executar script.
Regras inegociáveis
- Nunca modificar o conteúdo de um pacote. O byte que o Node lê é idêntico ao que o npm publicou.
- Sempre validar o
integrity. Hash divergente aborta o install inteiro antes de gravar qualquer coisa na store. - A store é append-only fora do GC. Deleção só no
brixel gc, com lock de arquivo exclusivo. - Falhar aberto, nunca fechado. Store corrompida, permissão negada, FICLONE indisponível: cai para o comportamento mais burro que funciona e avisa. Se um script aprovado falhar, o Brixel apaga o
node_modules, rodanpm installnormal e sai com sucesso — manifesto pela metade é pior que não usar a ferramenta.
Adoção no fluxo diário
Shim de shell, para instalar pacote novo:
brix() {
case "$1" in
install|i|add)
shift
npm install "$@" --package-lock-only || return 1
brixel install || { echo "brixel falhou, caindo pro npm"; npm install "$@"; }
;;
*)
npm "$@"
;;
esac
}--package-lock-only resolve e escreve o lockfile sem materializar nada e sem rodar script nenhum — por isso a allowlist de builds é obrigatória: nesse fluxo o Brixel é o único responsável pelos lifecycle scripts.
Git hook (post-checkout e post-merge), para trocar de branch e dar pull:
#!/bin/sh
[ -f .brixel/manifest.bin ] || exit 0
brixel install >/dev/null 2>&1 || trueSe o lockfile não mudou, o install cai no atalho e custa milissegundos.
Não use postinstall do package.json para isso: o npm já materializou o node_modules inteiro antes do hook rodar, então você paga o disco todo e só depois converte — e roda na máquina de todo mundo que clonar o repo.
Layout no disco
~/.brixel/
blobs/ab/3f9c8d... sharding pelos 2 primeiros chars do sha256
index.db sqlite: blobs, projects, packages, package_files
config.json compressão e capacidades de link por dispositivo
<projeto>/.brixel/
manifest.bin msgpack: caminho → hash + modo + tamanho
materialized hash do manifesto já materializado (atalho do install)O manifesto também grava a plataforma que o gerou (darwin-arm64, linux-x64-glibc, …). Manifesto de outra plataforma é tratado como manifesto velho e regerado, já que a lista de binários opcionais muda.
A tabela projects existe para o GC saber quais manifestos consultar. Não há refcount numérico — ele dessincroniza; o GC recalcula o conjunto vivo lendo os manifestos.
Desenvolvimento
npm install
npm run build # tsup → dist/index.js (ESM + shebang)
npm test # vitest
npm run typecheck # tsc --noEmit
npm run lint # biome check
npm run format # biome format --write
npm run verify # format:check + lint + typecheck + testVariáveis úteis: BRIXEL_HOME (store alternativa), BRIXEL_COMPRESSION, BRIXEL_NO_ANIMATION, BRIXEL_VERBOSE.
Medições
Medido em quatro projetos reais (Next 14, backend Node, monorepo Vite+API), macOS/APFS, store e projetos no mesmo dispositivo:
| Cenário | npm | brixel | |
|---|---|---|---|
| 1 projeto sozinho, sem dedup possível | 460 MB | 171 MB | −63% |
| 5 projetos com as mesmas dependências | 2300 MB | 478 MB | −79% |
| 4 projetos heterogêneos | 1725 MB | 1106 MB | −35% |
| Install do 5º projeto (store quente) | 5,7 s (npm ci) | 4,4 s | |
| restore de 22 mil arquivos | — | 4,1 s | |
| Monorepo (2 workspaces, 21 mil arquivos) | — | 18,7 s no 1º install | |
A economia acompanha a sobreposição entre os projetos: repositórios parecidos convergem para o custo de um só, projetos sem nada em comum economizam pouco. Nos quatro projetos, 22 579 de 22 604 arquivos foram hardlink e 25 viraram cópia (os do pacote com lifecycle script).
Fora de escopo no v1
Filesystem virtual (FUSE/FSKit/ProjFS), yarn.lock do berry, bun.lockb binário, daemon/watcher, telemetria, interface gráfica, filtro de arquivos "não usados" e sandbox dos lifecycle scripts. A allowlist é a única defesa do v1.
Licença
MIT — veja LICENSE. Pode fazer fork, usar em projeto comercial e fechar o código do seu; só mantenha o aviso de copyright.
