· Serhii Siryk · Development · 12 min de leitura
Domar o Desenvolvimento Local de Bibliotecas npm Sem as Dores do npm link

Se trabalha num monorepo e mantém bibliotecas partilhadas consumidas por múltiplos projetos externos, conhece a dor. O ciclo de feedback entre “alterei algo na biblioteca” e “vejo-o a funcionar na aplicação” é onde a felicidade do programador vai morrer. Este artigo explica o problema em detalhe e apresenta uma solução simples e sem complicações que tem funcionado bem em equipas de produção.
O Problema: Publicar, Instalar, Repetir
Imagine o seguinte cenário: tem uma biblioteca partilhada — digamos, um pacote de utilitários comuns — a viver no seu próprio monorepo. Vários outros projetos dependem dela: alguns serviços de backend NestJS e um frontend Angular. Todos eles estão a correr localmente enquanto desenvolve ativamente funcionalidades que abrangem tanto a biblioteca como as aplicações consumidoras.
Sempre que ajusta algo na biblioteca, precisa que os projetos consumidores detetem a alteração. A abordagem ingénua é publicar a biblioteca num registo npm privado e incrementar a versão. Isso significa:
- Incrementar a versão patch (ou publicar com a mesma versão e rezar para que corra bem)
- Executar o comando de publicação
- Mudar para cada projeto consumidor e executar
npm installou equivalente - Esperar que a instalação termine
- Observar o resultado
- Voltar ao passo 1
Se houver muitas alterações pequenas e exploratórias — o tipo de alterações que acontecem durante o desenvolvimento ativo — acaba por repetir este ciclo dezenas de vezes por dia. Isso mata o ímpeto.
Publicar com a mesma versão também é arriscado. O seu registo privado é partilhado: os colegas de equipa fazem pull dele, os pipelines de CI/CD fazem pull dele. Sobrescrever silenciosamente uma versão com código diferente é uma receita para bugs subtis e difíceis de reproduzir nos ambientes de outras pessoas. Por isso, incrementa a versão de cada vez, o que adiciona ruído ao histórico de commits e torna as releases sem sentido.
A Solução Óbvia: npm link
A primeira coisa a que a maioria dos programadores recorre é o npm link (ou os seus equivalentes no yarn e no pnpm). A ideia é elegante: em vez de instalar um tarball compilado, cria um symlink do código fonte da sua biblioteca local diretamente para o node_modules do projeto consumidor. Altera a biblioteca e o projeto consumidor vê imediatamente o novo código.
Na prática, funciona — até deixar de funcionar.
O Problema dos Vários Gestores de Pacotes
As bases de código do mundo real raramente têm o luxo de uma cadeia de ferramentas uniforme. Um projeto pode usar npm simples, outro yarn 1.x (clássico), outro yarn 4.x (Plug’n’Play) e ainda outro pnpm. Cada gestor de pacotes implementa a ligação (linking) de forma diferente:
- npm link cria um symlink global e uma contraparte local. O fluxo quebra-se facilmente quando a biblioteca tem o seu próprio
node_modulescom pacotes que também existem no projeto consumidor — acaba com duas instâncias separadas do mesmo módulo, causando erros de execução crípticos, como verificaçõesinstanceofa falharem através das fronteiras dos módulos. - yarn 1.x tem o
yarn link, que se comporta de forma semelhante mas tem o seu próprio conjunto de particularidades em torno do hoisting. As peer dependencies são particularmente dolorosas porque o yarn clássico faz hoisting de forma agressiva, pelo que a versão “errada” de uma peer dep pode ser resolvida a partir da pasta da própria biblioteca em vez da do consumidor. - yarn 4.x usa por predefinição o Plug’n’Play, que elimina completamente o
node_modules— a nossa abordagem requer que onode_modulesexista e não funciona com PnP. Dito isto, o yarn 4.x suporta voltar a um esquema tradicional através denodeLinker: node-modulesno.yarnrc.yml, e com essa definição a abordagem aqui descrita funciona bem. A história da ligação no modo PnP é um labirinto à parte. - pnpm usa um content-addressable store e hard links. O seu comando
pnpm linkfunciona novamente de forma diferente, e os symlinks para diretórios externos nem sempre funcionam bem com a resolução estrita de módulos do pnpm.
Diagnosticar problemas relacionados com links é notoriamente complicado. Os erros são frequentemente indiretos — uma violação de singleton inesperada, um aviso de peer dependency em falta que antes não existia, uma incompatibilidade de versão enterrada profundamente na árvore de resolução. Quando mistura gestores de pacotes entre projetos (o que é comum quando as equipas evoluem de forma independente), a resolução de problemas torna-se uma perda de tempo significativa.
O Problema do Servidor de Desenvolvimento
Mesmo quando a ligação funciona do ponto de vista da resolução de módulos, existe um problema mais subtil: os servidores de desenvolvimento e os file watchers.
Considere uma configuração local típica: dois serviços NestJS geridos pelo nodemon e um frontend Angular servido por ng serve. Os três consomem a biblioteca partilhada. Tem a compilação da biblioteca em modo watch, pelo que, sempre que guarda um ficheiro na biblioteca, ela é recompilada.
Com o npm link, a pasta dist/ da biblioteca é ligada por symlink ao node_modules de cada consumidor. Quando a biblioteca é recompilada, tipicamente limpa primeiro a dist/ e depois escreve os novos ficheiros. Essa sequência — apagar e depois escrever — confunde muitos file watchers:
- nodemon normalmente lida com isto de forma aceitável, embora ocasionalmente precise de um reinício manual.
- o
ng servedo Angular lida com isto de forma deficiente. Quando adist/ligada é limpa durante uma recompilação, o watcher deteta a eliminação e entra num estado de erro. As escritas de ficheiros subsequentes nem sempre despoletam uma recuperação limpa. Frequentemente, acaba com um servidor de desenvolvimento avariado que requer um reinício completo. E depois o próprio symlink pode precisar de ser restabelecido. Vai-se acumulando soluções alternativas sobre soluções alternativas: flags--preserve-symlinks, aliases webpack personalizados, file watchers baseados em polling — cada uma adicionando fragilidade e sobrecarga cognitiva.
Cada stack tecnológica tem a sua própria variante destes problemas. O que funciona para uma combinação de bundler, gestor de pacotes e servidor de desenvolvimento pode não funcionar para outra, e a documentação para casos extremos está dispersa por issues do GitHub e tópicos do Stack Overflow.
Ferramentas Existentes Que Tentam Resolver Isto
Sendo justos, a comunidade reconheceu este problema e construiu ferramentas dedicadas:
- Yalc — uma ferramenta que imita um registo npm local, permitindo fazer
yalc publishde uma biblioteca eyalc addnos projetos consumidores. É mais fiável do que symlinks simples, mas introduz outra ferramenta CLI, um store global para gerir e comandos específicos a aprender. Os projetos precisam de estar cientes das dependências adicionadas pelo Yalc no seupackage.json. - Verdaccio — um registo npm completo e autoalojado que pode executar localmente. Resolve o problema por completo, mas ao custo de executar um serviço em segundo plano, configurar o
.npmrcem cada projeto e gerir um registo local apenas para fins de desenvolvimento. - npx link — um substituto direto (drop-in) e mais seguro do
npm link, pelo mesmo autor que escreveu uma análise detalhada de porque deve evitar-se onpm link. Corrige várias das piores armadilhas do npm link: não instala globalmente o pacote nem os seus binários, não remove pacotes previamente ligados quando adiciona um novo e funciona de forma consistente entre versões do Node.js. Também tem um modo de publicação que usa hard links em vez de symlinks para replicar mais de perto umnpm installreal. É uma melhoria significativa em relação aonpm linksimples, mas continua a ser uma nova dependência a aprender e não resolve todas as questões de conveniência que tentamos resolver.
Todas estas são ferramentas legítimas, com valor real. Mas cada uma introduz sobrecarga: novos comandos a aprender, configuração adicional, serviços extra a executar. Se o seu objetivo é a simplicidade e já tem um pipeline de build em que confia, existe um caminho mais leve.
Uma Abordagem Mais Simples: Pack e Extract
A ideia central é a seguinte: o npm pack produz um tarball .tgz estruturalmente idêntico ao que é publicado num registo. Se extrair esse tarball diretamente para o node_modules de um projeto consumidor, o resultado é indistinguível de um npm install real. Sem symlinks. Sem registo. Sem magia.
É exatamente isto que um pequeno script Node.js — update-package.js — faz. Vive no repositório da biblioteca e está integrado no pipeline de build através de um target Nx. Eis como funciona de ponta a ponta.
O Script
// update-package.js - Replace npm package with tarball contents in local projects
// Usage: MYORG_PROJECTS=/path1,/path2 node update-package.js <package> <tarball>
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const [, , packageName, tarballPattern] = process.argv;
if (!tarballPattern || !packageName) {
console.error('Usage: MYORG_PROJECTS=/path1,/path2 node update-package.js <package> <tarball>');
process.exit(1);
}
const projects = process.env.MYORG_PROJECTS?.split(',')
.map((p) => p.trim())
.filter((p) => p);
if (!projects?.length) {
console.error('Error: MYORG_PROJECTS env var not set');
console.error('Example: MYORG_PROJECTS=/path/to/project1,/path/to/project2');
process.exit(1);
}
const tarballPath = path.resolve(tarballPattern);
if (!fs.existsSync(tarballPath)) {
console.error(`Error: Tarball not found: ${tarballPath}`);
process.exit(1);
}
const [scope, name] = packageName.split('/');
let updatedCount = 0;
let skippedCount = 0;
for (const repoRoot of projects) {
const targetDir = path.join(repoRoot, 'node_modules', scope);
const packageDir = path.join(targetDir, name);
if (!fs.existsSync(packageDir)) {
console.log(`⏭️ Skipping ${packageName} in ${repoRoot} (not installed)`);
skippedCount++;
continue;
}
console.log(`📦 Updating ${packageName} in ${repoRoot}...`);
try {
execSync(
`rm -rf "${packageDir}" && ` +
`mkdir -p "${targetDir}" && ` +
`tar -xzf "${tarballPath}" -C "${targetDir}" && ` +
`mv "${path.join(targetDir, 'package')}" "${packageDir}"`,
{ stdio: 'pipe' }
);
updatedCount++;
} catch (error) {
console.error(`❌ ${repoRoot}: ${error.message}`);
}
}
console.log(`✅ Updated ${packageName} in ${updatedCount} project(s), skipped ${skippedCount}\n`);
O script lê uma variável de ambiente MYORG_PROJECTS que contém uma lista de caminhos de projetos locais separados por vírgulas. Para cada biblioteca a atualizar, verifica se essa biblioteca está instalada no node_modules do projeto de destino. Se não estiver instalada, ignora silenciosamente — sem ruído, sem erros. Se estiver instalada, remove o diretório antigo e extrai o novo tarball no seu lugar.
A lógica de atualização seletiva é importante: pode ter três projetos consumidores, mas apenas dois deles usam uma determinada biblioteca. O script trata disso automaticamente, sem qualquer configuração por projeto.
O Target Nx
O script é invocado através de um target Nx publish:local definido no project.json da biblioteca:
"publish:local": {
"executor": "nx:run-commands",
"dependsOn": ["build:libs"],
"options": {
"commands": [
"cd dist/libs/shared && npm pack",
"cd dist/libs/nest-shared && npm pack",
"cd dist/libs/ng-shared && npm pack",
"cd dist/libs/nextjs-shared && npm pack",
"node scripts/update-package.js @myorg/shared dist/libs/shared/*.tgz",
"node scripts/update-package.js @myorg/nest-shared dist/libs/nest-shared/*.tgz",
"node scripts/update-package.js @myorg/ng-shared dist/libs/ng-shared/*.tgz",
"node scripts/update-package.js @myorg/nextjs-shared dist/libs/nextjs-shared/*.tgz"
],
"parallel": false
}
}
O dependsOn: ["build:libs"] garante que as bibliotecas são sempre compiladas de novo antes de serem empacotadas. Os comandos são executados sequencialmente: compilar, empacotar cada biblioteca num tarball e, em seguida, distribuir cada tarball para todos os projetos configurados. Todo o pipeline é despoletado com um único comando:
yarn nx run @myorg/source:publish:local
Configuração
A única configuração necessária é uma variável de ambiente shell a apontar para os projetos consumidores:
# Single project
export MYORG_PROJECTS="$HOME/projects/project-1"
# Multiple projects
export MYORG_PROJECTS="$HOME/projects/project-1,$HOME/projects/project-2"
Adicione isto ao seu .zshenv ou .bashrc para persistência. Nenhum caminho absoluto aparece em ficheiros de configuração commitados no repositório — os caminhos vivem no ambiente local do programador, que é onde devem estar.
O Aspeto da Saída
O script é executado uma vez por biblioteca, e o comando publish:local completo processa todas elas em sequência. Eis o aspeto de uma execução típica em três projetos registados com quatro bibliotecas no monorepo:
📦 Updating @myorg/shared in $HOME/projects/project-1...
📦 Updating @myorg/shared in $HOME/projects/project-2...
⏭️ Skipping @myorg/shared in $HOME/projects/project-3 (not installed)
✅ Updated @myorg/shared in 2 project(s), skipped 1
📦 Updating @myorg/nest-shared in $HOME/projects/project-2...
⏭️ Skipping @myorg/nest-shared in $HOME/projects/project-1 (not installed)
⏭️ Skipping @myorg/nest-shared in $HOME/projects/project-3 (not installed)
✅ Updated @myorg/nest-shared in 1 project(s), skipped 2
📦 Updating @myorg/ng-shared in $HOME/projects/project-1...
⏭️ Skipping @myorg/ng-shared in $HOME/projects/project-2 (not installed)
⏭️ Skipping @myorg/ng-shared in $HOME/projects/project-3 (not installed)
✅ Updated @myorg/ng-shared in 1 project(s), skipped 2
⏭️ Skipping @myorg/nextjs-shared in $HOME/projects/project-1 (not installed)
⏭️ Skipping @myorg/nextjs-shared in $HOME/projects/project-2 (not installed)
⏭️ Skipping @myorg/nextjs-shared in $HOME/projects/project-3 (not installed)
✅ Updated @myorg/nextjs-shared in 0 project(s), skipped 3
Algumas coisas dignas de nota aqui. Cada biblioteca é distribuída de forma independente: @myorg/shared vai para o project-1 e o project-2 porque a têm no seu node_modules, enquanto o project-3 não a utiliza e é silenciosamente ignorado. @myorg/nest-shared só chega ao project-2 — um pacote apenas de backend, para o qual o frontend e o terceiro projeto não têm qualquer utilidade. O @myorg/nextjs-shared dá-se o caso de não estar instalado em lado nenhum no conjunto atual de projetos, pelo que produz zero atualizações sem qualquer erro. O script nunca toca num projeto que ainda não tem o pacote e nunca exige que declare qual projeto usa qual biblioteca — simplesmente verifica o node_modules e age em conformidade.
Porque Isto Funciona Melhor do que o Linking
A diferença crítica é que, depois de executar o script, o node_modules do projeto consumidor contém uma instalação real, não um symlink. Da perspetiva de cada ferramenta da cadeia — o bundler, o servidor de desenvolvimento, o file watcher, o resolvedor de módulos —, o pacote é apenas mais uma dependência instalada.
Isto significa:
- O
ng servedo Angular deteta a atualização de forma limpa na próxima alteração de ficheiro, porque não há nenhum symlink para quebrar nem nenhum diretóriodist/a ser apagado por baixo de si. - nodemon reinicia normalmente, porque os ficheiros que vigia mudam atomicamente do seu ponto de vista.
- A resolução de módulos é consistente, porque existe apenas uma cópia de cada pacote num caminho bem conhecido, independentemente do gestor de pacotes que o projeto consumidor usa.
- As peer dependencies resolvem-se corretamente, porque o pacote está disposto exatamente como uma instalação real o disporia.
A contrapartida é que a atualização não é instantânea — despoleta-a manualmente depois de fazer as alterações. Mas um único comando que demora alguns segundos é muito melhor do que um servidor de desenvolvimento avariado, uma sessão de depuração para localizar um erro de módulo duplicado, ou um ciclo de publicar-instalar repetido cinquenta vezes por dia.
Resumo
A abordagem não é sofisticada, e é essa a ideia. Apoia-se nas ferramentas npm standard — npm pack e tar — para produzir e distribuir pacotes num formato que cada ferramenta consumidora já percebe perfeitamente. Não há nova abstração a aprender, nenhum serviço em segundo plano a executar e nenhuns ficheiros de configuração para manter sincronizados entre projetos.
Se gere bibliotecas partilhadas consumidas por vários projetos locais e se cansou de lutar com os casos extremos do npm link, este padrão vale a pena experimentar. Coloque o script no repositório da sua biblioteca, integre-o no seu sistema de build, exporte uma variável de ambiente e volte a escrever código.



