npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@chrono-os/links

v0.1.1

Published

Encurtador de links com rastreio LGPD: slug único, destino só http/https, 302 por padrão (301 opcional), clique com IP em HMAC diário e estatísticas por dia e por origem. Persistência por interface; adaptador Prisma e schema de referência inclusos. Zero d

Downloads

275

Readme

@chrono-os/links

Encurtador de links com rastreio que respeita a LGPD. Cria, edita e lista links com slug único; recusa destino que não seja http/https; resolve o slug com 302 por padrão (301 opcional); registra o clique com o IP em HMAC diário, nunca em texto; e agrega estatísticas por dia e por origem.

Não depende de framework nem de banco. A persistência é uma interface (RepositorioLinks) que o app implementa sobre o próprio schema. O pacote traz um adaptador Prisma pronto para o schema de referência e um repositório em memória para teste. Zero dependências de runtime.

yarn add @chrono-os/links

Uso

import { criarServicoLinks, criarRepositorioPrisma } from '@chrono-os/links'

export const links = criarServicoLinks({
  repositorio: criarRepositorioPrisma({ redirect: prisma.redirect, clickLog: prisma.clickLog }),
  segredoIp: env.LINKS_IP_SECRET,          // 16+ caracteres: openssl rand -hex 32
  slugsReservados: ['admin', 'api', 'health', 'propostas'],
  validacaoUrl: { hostsProibidos: ['lnk.exemplo.com.br'] }, // o próprio encurtador
  fusoHorario: 'America/Sao_Paulo',        // define "dia" nas estatísticas
  aoMudar: (e) => auditoria.registrar(e),  // opcional
})

await links.criar({ slug: 'setembro', targetUrl: 'https://exemplo.com.br/lp', title: 'Campanha de setembro' })
await links.atualizar(id, { targetUrl: 'https://exemplo.com.br/lp-v2' })
await links.listar()                        // mais novo primeiro, com totalCliques
await links.arquivar(id)                    // soft delete; o slug continua ocupado
await links.desarquivar(id)                 // volta INATIVO
await links.excluirDefinitivamente(id)      // só depois de arquivar
await links.estatisticas(id, { desde })

Erros saem como subclasses de ErroLinks, com codigo e statusHttp sugerido: ErroSlugInvalido e ErroSlugReservado (422), ErroSlugEmUso (409), ErroUrlDestinoInvalida (422, com motivo), ErroEntradaInvalida (422), ErroLinkNaoEncontrado (404), ErroLinkNaoArquivado (409). ehErroLinks(e) reconhece qualquer um deles mesmo com as builds ESM e CJS carregadas ao mesmo tempo.

Rota pública

O núcleo devolve status, Location e headers; aplicar na resposta leva três linhas em qualquer framework.

Express ou Nest (@Res()):

const acesso = await links.acessar(req.params.slug, dadosDaRequisicao({ headers: req.headers, ip: req.ip, url: req.originalUrl }))
if (!acesso) return res.status(404).send(paginaNaoEncontrada)
for (const [k, v] of Object.entries(acesso.headers)) res.setHeader(k, v)
res.status(acesso.status).setHeader('Location', acesso.location)
res.setHeader('Content-Length', '0').end()   // res.redirect() reescreve a URL com encodeUrl()

Route handler do Next (Vercel):

import { waitUntil } from '@vercel/functions'

export async function GET(req: Request, { params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const acesso = await links.acessar(slug, dadosDaRequisicao({ headers: req.headers, url: req.url, confiarXForwardedFor: true }))
  if (!acesso) return new Response('Link não encontrado', { status: 404 })
  waitUntil(acesso.clique)                   // sem isso a função congela e o clique se perde
  return new Response(null, { status: acesso.status, headers: { ...acesso.headers, Location: acesso.location } })
}

acessar resolve o slug e dispara a gravação do clique sem esperar por ela. A gravação nunca rejeita: se o banco falhar, o erro vai para aoFalhar e a pessoa chega ao destino do mesmo jeito. A query do acesso é repassada ao destino (as UTMs de quem clicou chegam na página); se o destino já tem a mesma chave, vale a dele. Desligue com propagarQuery: false.

302 ou 301

O padrão é 302 (temporário). O navegador pergunta ao encurtador a cada clique, então todo clique é contado e trocar o destino vale na hora para todo mundo. A resposta sai com Cache-Control: no-store e X-Robots-Tag: noindex, nofollow.

301 (permanente) é guardado pelo navegador. A partir do segundo clique da mesma pessoa, o navegador vai direto ao destino sem passar pelo encurtador: o clique repetido some da contagem, e quem já clicou continua indo ao destino antigo depois de uma troca. Serve para migrar URL antiga preservando SEO; para link de campanha, não. Escolha por link (redirectType: 301) ou mude o padrão do serviço (tipoPadrao).

Destino

validarUrlDestino aceita só http: e https:. Recusa, com o motivo no erro:

| Entrada | Motivo | |---|---| | javascript:…, data:…, vbscript:…, file:…, ftp:… | protocolo | | //golpe.com, /\golpe.com | protocolo-relativo (o navegador lê como outro host) | | https://[email protected] | credenciais | | caractere de controle (CR, LF, TAB) | caractere-de-controle | | host em hostsProibidos ou subdomínio dele | host-proibido | | /caminho sem permitirCaminhoRelativo | relativa | | mais de 2048 caracteres | longa-demais |

Um javascript: gravado não executa no redirect (navegador ignora esse Location), mas vira XSS no painel que mostra o destino como link. As implementações anteriores validavam com z.string().url() do Zod 3, que aceita javascript:alert(1) e data:.

O valor gravado é o que a pessoa digitou, só aparado; o pacote não normaliza a URL.

Clique e LGPD

Cada clique grava:

| Campo | O que vai | Por quê | |---|---|---| | ipHash | HMAC-SHA256 com chave segredo:AAAA-MM-DD (UTC), base64url | Mesmo algoritmo de hashIpHmacDiario do @chrono-os/consent-lgpd 0.7 e do admin-audit. Sal fixo deixa reverter o hash varrendo o IPv4 (2³² tentativas); com a chave do dia, a correlação do mesmo IP fica limitada a 24 h | | userAgent | resumido: Chrome/128 · Android | a string inteira é superfície de fingerprint | | deviceType | mobile, tablet, desktop, bot, unknown | prévia de link do WhatsApp e do Telegram conta como bot | | referrer | só o host (l.instagram.com) | a URL de quem indicou pode trazer e-mail ou token na query | | country | ISO-2 do header da CDN | | | utmSourceutmContent | as cinco UTMs, até 200 caracteres | |

segredoIp é obrigatório (16+ caracteres). Sem ele o serviço não sobe: hash de IP sem segredo é reversível.

Estatísticas

estatisticas(id, { desde, ate, limiteTop, excluirBots }) devolve totalCliques, cliquesHoje, cliques7d, visitantesUnicosDiarios, cliquesSemOrigem, a série porDia (sem buraco: dia sem clique vale 0) e os rankings porOrigem, porUtmSource, porUtmMedium, porUtmCampaign, porDispositivo e porPais.

visitantesUnicosDiarios é a soma, dia a dia, dos IPs distintos. Não é "pessoas distintas no período": como a chave do HMAC troca todo dia, a mesma pessoa em dois dias conta duas vezes. As implementações anteriores usavam sal fixo e contavam pessoas distintas no período inteiro; o número novo é maior para link com retorno. Dado antigo com sal fixo é contado do mesmo jeito (par dia + hash), então a métrica não muda de natureza no meio da série.

A agregação é feita em memória sobre listarCliques (como no links-manager). Vai bem até a casa de 10⁵ cliques por consulta. Acima disso, agregue no banco e monte o mesmo formato; agregarEstatisticas é pura e exportada.

Repositório próprio

Schema diferente do de referência implementa a interface direto. São nove métodos:

interface RepositorioLinks {
  buscarPorId(id): Promise<Link | null>
  buscarPorSlug(slug): Promise<Link | null>            // inclui arquivados
  listar({ incluirArquivados }): Promise<Link[]>
  criar(dados): Promise<Link>                          // lança ErroSlugEmUso no conflito do banco
  atualizar(id, alteracoes): Promise<Link>             // idem para troca de slug
  excluir(id): Promise<void>                           // cliques vão junto
  registrarClique(clique): Promise<void>
  listarCliques(linkId, { desde, ate }): Promise<Clique[]>
  contarCliques(ids): Promise<Record<string, number>>
}

O serviço confere o slug antes de gravar, mas só a constraint do banco fecha a corrida entre dois criar simultâneos. Por isso o repositório deve traduzir o erro de unicidade (P2002 no Prisma) em ErroSlugEmUso, como o adaptador pronto faz.

App com várias marcas no mesmo banco (coluna property) usa camposFixos: criarRepositorioPrisma({ …, camposFixos: { property: 'lealferraz' } }), um serviço por marca. O pacote não sabe o que a coluna significa.

Schema de referência

prisma/schema.prisma, também exportado como @chrono-os/links/schema.prisma. Tabelas e colunas são as do links-manager do Naírio. Quem já tem esse formato só precisa de uma coluna:

ALTER TABLE "redirects" ADD COLUMN "redirect_type" INTEGER NOT NULL DEFAULT 302;

Validado com Prisma 7.10 (prisma validate, db push) e com o adaptador rodando contra Postgres 16.

Origem

Unifica quatro implementações: links-manager-backend do Naírio (a fonte), redirects do nairio-institucional, src/lib/links do lealferraz-dashboard e o módulo links do sobrevivendo-backend. O CHANGELOG registra o que veio de cada uma e o que mudou.