Criando adaptadores de fonte para telemetria

Como adaptadores separados transformam respostas do Spotify e do GitHub em retratos compactos e tipados, cada um com suas regras de polling, privacidade, cache e limites de uso da API.

25 de jul. de 2026

Não quero esconder um cliente de API dentro de um componente do painel. Um adaptador pequeno deve consultar cada fonte e decidir quanto tempo o dado continua atual, quais campos guardar, por quanto tempo mantê-los e o que publicar após uma falha.

Spotify e GitHub são exemplos contrastantes. A reprodução do Spotify pode se tornar enganosa em poucos segundos e pode nem existir. Os dados de contribuições do GitHub mudam devagar, podem ser derivados de uma resposta maior e valem um cache entre reinicializações do processo. Ainda assim, as duas fontes precisam chegar à mesma camada de estatísticas do navegador.

APIs de origemSpotify · GitHub GraphQLAdaptadores de origemSpotify · GitHubSchemas compartilhadosMódulos de estatísticasFluxo SSEPainéis Solid

O adaptador conversa com o sistema externo. O painel renderiza o resultado. Se um provedor cair, seu adaptador decide o que publicar e o painel continua recebendo o mesmo contrato.

Comece pelo contrato de saída

A interface comum dos módulos é deliberadamente pequena:

export type StatModule<T> = {
  start: (...args: any[]) => void;
  getLatest: () => T;
  getHistory: () => T[];
  getVersion: () => number;
};

Cada módulo tem uma tarefa: publicar retratos quando sua fonte mudar. getLatest alimenta o estado atual, getHistory fornece a visão inicial e getVersion permite que a rota SSE detecte mudanças sem enviar o mesmo valor repetidamente.

A interface genérica não torna os dados intercambiáveis. Cada fonte tem seu próprio schema em shared/stats. O Valibot valida os dados persistidos do GitHub antes do uso, enquanto os tipos compartilhados mantêm o código do servidor, a serialização do transporte e os stores do Solid alinhados.

O retrato público também é uma fronteira de segurança. Ele deve conter exatamente o que o painel precisa. Credenciais do provedor, respostas brutas, metadados do dispositivo e métodos de controle ficam no servidor.

Dê a cada fonte o seu próprio relógio

O intervalo de polling deve acompanhar o que o visitante vê:

Fonte Atualidade útil Política local Persistência
Spotify Poucos segundos durante a reprodução 2,5 s ativo, 15 s parado Apenas histórico em memória
GitHub Vários minutos 30 min após sucesso Retrato validado em disco

Um timer compartilhado de cinco minutos seria inadequado para as duas fontes. Ele deixaria o Spotify desatualizado e consultaria o GitHub com mais frequência do que a interface precisa.

Adaptador 1: reduza o Spotify a um retrato seguro

A integração com o Spotify tem duas etapas de autenticação. O refresh token fica nas variáveis de ambiente do servidor. server/stats/spotify.ts troca esse token por um access token de curta duração e o mantém em memória até um minuto antes do vencimento informado:

if (tokenCache && tokenCache.expiresAt > Date.now() + TOKEN_EXPIRY_SAFETY_MARGIN_MS) {
  return tokenCache.accessToken;
}

const response = await fetch(SPOTIFY_TOKEN_ENDPOINT, {
  method: "POST",
  headers: {
    authorization: createBasicAuthHeader(clientId, clientSecret),
    "content-type": "application/x-www-form-urlencoded",
  },
  body: new URLSearchParams({
    grant_type: "refresh_token",
    refresh_token: refreshToken,
  }),
});

O navegador nunca recebe o refresh token ou o client secret. Depois da autenticação, o adaptador transforma a resposta do provedor nos campos que o painel pode renderizar:

type SpotifyNowPlaying = {
  isConfigured: boolean;
  isPlaying: boolean;
  trackId: string | null;
  trackName: string | null;
  artistNames: string[];
  albumName: string | null;
  trackUrl: string | null;
  progressMs: number;
  durationMs: number;
  fetchedAt: number;
};

Essa normalização trata três casos diferentes de “vazio” sem obrigar a interface a conhecer o formato da resposta do Spotify:

  • HTTP 204 significa que não há reprodução atual.
  • Um item que não é uma faixa vira o mesmo retrato vazio.
  • A falta de credenciais produz isConfigured: false em vez de uma exceção no fluxo de estatísticas.

O intervalo de polling acompanha o retrato atual em vez de permanecer fixo:

function getPollIntervalMs(isPlaying: boolean) {
  return isPlaying ? 2_500 : 15_000;
}

Quando o Spotify responde com 429, o adaptador lê Retry-After e usa 30 segundos como fallback. Se o access token for rejeitado, o cache em memória é invalidado para que a próxima tentativa possa renová-lo. Outras falhas de requisição publicam um retrato configurado vazio e continuam no intervalo mais lento.

O histórico da reprodução fica na memória. O módulo mantém no máximo 84 retratos, o suficiente para o painel mostrar uma faixa anterior sem gravar a atividade de escuta em um banco de dados. Uma reinicialização apaga o histórico.

Adaptador 2: derive as métricas do GitHub uma vez

A integração com o GitHub usa a estratégia oposta. server/stats/github.ts solicita o calendário de contribuições desde o início do ano com uma consulta GraphQL e deriva localmente todos os valores do painel:

Calendário do ano atéhojeDias de contribuiçãoTotais atuaishoje · mês · anoAtividade recente30 dias · último dia ativo

A derivação usa strings de data ISO como chaves. Isso fornece um formato estável para comparar os limites do ano e do mês, enquanto a série de 30 dias é criada a partir da janela de datas local para que dias sem contribuições apareçam como zero.

O retrato resultante contém apenas agregados prontos para a interface:

type GitHubCommitStats = {
  isConfigured: boolean;
  username: string;
  lastCommitDate: string | null;
  commitsToday: number;
  commitsLast30Days: number[];
  commitsLast30DayLabels: string[];
  commitsThisMonth: number;
  commitsThisYear: number;
  fetchedAt: number;
};

O painel usa “commits” como atalho para o calendário de contribuições do GitHub. Não é um git log local nem uma auditoria de todos os commits.

Armazene apenas o estado normalizado

Os dados do GitHub são lentos o suficiente para sobreviver a uma reinicialização. Depois de uma consulta bem-sucedida, o adaptador grava o retrato normalizado em github-cache.json, no diretório de dados da aplicação. Durante a inicialização, ele:

  1. Verifica se o arquivo existe.
  2. Faz o parse com o schema compartilhado do Valibot.
  3. Usa o arquivo somente enquanto ele tiver menos de 30 minutos.
  4. Agenda a próxima requisição para o fim da janela de validade restante.

O cache guarda o objeto normalizado, então o restante da aplicação não precisa interpretar novamente a resposta do provedor. O Valibot transforma um esquema antigo ou um arquivo malformado em cache miss, em vez de deixá-lo entrar no fluxo.

A política de erro separa “tentar mais tarde” de “mostrar um novo estado”. Quando recebe um limite, o adaptador lê X-RateLimit-Reset e preserva o último retrato válido até aquele horário. Se o header estiver ausente, espera 15 minutos. Outras falhas publicam um retrato configurado vazio e repetem a tentativa no intervalo mais longo. O painel não apresenta dados antigos como atuais, e um limite temporário não apaga o último valor válido.

Mantenha o painel alheio ao provedor

Os painéis do Solid consomem os retratos normalizados através do transporte de estatísticas. Eles não renovam credenciais, interpretam Retry-After, leem arquivos de cache nem sabem com que frequência um provedor deve ser consultado.

Cada adaptador passa a concentrar as decisões específicas de sua fonte:

Preocupação Adaptador do Spotify Adaptador do GitHub
Autenticação Refresh token trocado em memória Token do servidor na requisição GraphQL
Normalização Faixa atual ou estado vazio explícito Dias de contribuição em métricas agregadas
Atualidade Polling ativo/parado adaptativo Polling fixo de 30 minutos
Limite Retry-After X-RateLimit-Reset
Retenção 84 retratos em memória 84 retratos em memória e um retrato fresco em disco
Payload do navegador Metadados da faixa e URL pública Valores agregados de contribuição

Os dois provedores seguem relógios e regras de falha diferentes, mas publicam retratos tipados e pequenos que a interface consegue renderizar diretamente.

O endpoint de histórico carrega os dois módulos por esse contrato. A rota SSE emite apenas as versões alteradas, e os painéis renderizam o estado sem repetir a lógica das APIs dos provedores.