Rol del emisor
Signal Client existe para publicar eventos desde servicios externos hacia un límite de integración orientado al bot. Aunque el emisor sea un endpoint de API, un worker cron o una Server Action de Next.js, el objetivo operativo siempre es el mismo: impulsar comportamiento del bot de forma controlada y auditable. El paquete se encarga de construcción de envelope, firma HMAC, rotación de webhooks y absorción de rate-limits para que el código productor se mantenga enfocado en lógica de negocio.
Esta perspectiva evita que los equipos conviertan el cliente en una herramienta de mensajería genérica. El cliente aporta más valor cuando tus servicios necesitan una forma segura y consistente de notificar dominios del bot sobre cambios de estado sin acoplarse a internals del gateway de Discord.
Contrato del constructor
SignalClient recibe un único objeto SignalClientConfig y un FallbackStore personalizado opcional. El constructor crea un SignalEnvelopeBuilder interno para firma HMAC, un WebhookPool para entrega round-robin y, cuando está habilitado, un FallbackWorker que arranca de inmediato. Cada campo, salvo webhookUrls y hmacSecret, tiene defaults razonables, así que una configuración mínima solo requiere esos dos valores.
| Campo | Tipo | Default | Propósito |
|---|---|---|---|
| webhookUrls | string[] | required | Una o más URLs de webhook de Discord apuntando al canal privado de signal. El pool rota entre ellas en orden round-robin. |
| hmacSecret | string | required | Secreto compartido HMAC-SHA256. Debe coincidir con hmacSecret configurado en @spraxium/signal del lado del bot. |
| rateLimitStrategy | skip | wait | skip | Comportamiento cuando todos los webhooks están rate-limited al mismo tiempo. Ver sección de estrategias más abajo. |
| fallback | FallbackConfig | undefined | Habilita sistema de reintento persistente de fallback. Se cubre en la página de confiabilidad. |
import { SignalClient } from '@spraxium/signal-client';
const client = new SignalClient({
webhookUrls: [
process.env.SIGNAL_WEBHOOK_URL_1!,
process.env.SIGNAL_WEBHOOK_URL_2!,
],
hmacSecret: process.env.SIGNAL_HMAC_SECRET!,
rateLimitStrategy: 'skip',
});El secreto compartido en hmacSecret debe coincidir exactamente con la configuración del lado del bot. Si los valores divergen, el bot rechaza cada envelope en la verificación HMAC (regla R10) y no se ejecuta ningún listener.
Construcción y firma del envelope
Cada llamada a send delega internamente en SignalEnvelopeBuilder, que produce un envelope completo firmado criptográficamente. El builder genera un UUID aleatorio como nonce, captura el timestamp actual como sentAt y calcula la firma HMAC-SHA256 sobre la concatenación nonce.sentAt.JSON.stringify(payload). Esta fórmula es idéntica a la usada por @spraxium/signal en el lado receptor, así que ambos extremos comparten el mismo contrato de firma sin negociación adicional.
El envelope resultante siempre lleva versión de protocolo 1, nombre de evento, guild ID objetivo y payload junto a los campos criptográficos. El código productor nunca necesita interactuar directamente con el builder porque send encapsula todo el flujo.
| Campo del envelope | Tipo | Origen |
|---|---|---|
| v | 1 | Versión de protocolo hardcodeada. |
| event | string | Primer argumento de send. |
| guildId | string | Segundo argumento de send. |
| nonce | string | UUID aleatorio generado por la función UUID del runtime. |
| sentAt | number | Timestamp Unix en milisegundos capturado al enviar. |
| signature | string | Digest hexadecimal HMAC-SHA256 del nonce, timestamp y payload serializado. |
| payload | Record<string, unknown> | Tercer argumento de send, con default de objeto vacío. |
El método send
send es la API principal. Acepta nombre de evento, guild ID, payload opcional y SendOptions opcional. El flujo interno construye el envelope, toma el siguiente webhook disponible del pool y entrega por HTTP POST. Si el webhook responde 429, el pool lo marca como rate-limited y prueba de inmediato el siguiente candidato. Cuando se agotan todos los webhooks, el comportamiento depende de rateLimitStrategy.
await client.send('billing.status.changed', guildId, {
plan: 'premium',
expiresAt: '2025-12-31',
});La interfaz SendOptions expone dos campos opcionales. El campo webhookIndex fuerza entrega por un webhook específico, omitiendo la rotación round-robin. El campo skipFallback se usa internamente por FallbackWorker para evitar loops de reencolado durante reintentos.
| Opción | Tipo | Default | Propósito |
|---|---|---|---|
| webhookIndex | number | undefined | undefined | Fuerza un índice de webhook específico (base cero). Lanza RangeError si está fuera de rango. |
| skipFallback | boolean | undefined | undefined | Cuando es true, las fallas no se encolan en el fallback store. Se usa internamente por el sistema de reintentos. |
Rotación del pool de webhooks
WebhookPool distribuye envíos entre webhooks configurados usando un cursor round-robin. Cuando un webhook devuelve HTTP 429, se marca como rate-limited por la duración indicada en el header Retry-After, y el pool lo omite automáticamente hasta que expire ese cooldown. Agregar múltiples URLs de webhook mejora mucho la resiliencia en ráfagas porque el cliente siempre tiene endpoints alternativos para probar.
El pool expone varios métodos de inspección útiles para monitoreo y debugging. Puedes acceder al pool en cualquier momento mediante la propiedad pool de la instancia del cliente.
| Método / Propiedad | Retorna | Propósito |
|---|---|---|
| next | | null | Devuelve el siguiente webhook disponible en orden round-robin. Devuelve null cuando todos están rate-limited. |
| get | string | Devuelve el webhook de un índice específico, omitiendo checks de rate-limit. Lanza RangeError si está fuera de rango. |
| markRateLimited | void | Marca un webhook como rate-limited por una duración dada en milisegundos. Se llama automáticamente ante 429. |
| msUntilAvailable | number | Devuelve milisegundos hasta que el webhook más próximo quede disponible. Retorna 0 cuando al menos uno está listo. |
| status | WebhookStatus[] | Snapshot de solo lectura de cada webhook con index, url, available y rateLimitedUntil. |
| size | number | Número total de webhooks en el pool. |
const poolStatus = client.pool.status();
for (const entry of poolStatus) {
console.log(
`Webhook #${entry.index}: ${entry.available ? 'ready' : `cooldown until ${entry.rateLimitedUntil}`}`,
);
}Estrategias de rate-limit
Cuando todos los webhooks del pool están rate-limited al mismo tiempo, el cliente debe decidir qué hacer. El campo rateLimitStrategy controla ese comportamiento. Ambas estrategias son válidas según tu arquitectura de servicio.
| Estrategia | Comportamiento | Mejor ajuste |
|---|---|---|
| skip | Lanza AllWebhooksRateLimitedError inmediatamente. El error incluye el estado completo del pool con timestamps de cooldown para que el caller decida cuándo reintentar. | Servicios que ya tienen su propia orquestación de reintentos o infraestructura de colas. |
| wait | Espera hasta que el webhook más próximo salga de cooldown (vía msUntilAvailable) y luego reintenta entrega exactamente una vez. Si también falla, lanza error. | Absorción de ráfagas cortas dentro del cliente sin lógica externa de reintentos. |
El default es 'skip' porque mantiene al cliente no bloqueante. Para la mayoría de contextos de Server Actions o handlers de API donde una latencia breve es aceptable, 'wait' ofrece mejor ergonomía al absorber picos transitorios de rate-limit de forma transparente.
Tipos de error
El cliente expone dos clases de error que entregan información estructurada de fallas. Ambas extienden Error y cargan campos legibles por máquina para manejo programático.
SignalClientError se lanza ante respuestas HTTP no 2xx y no 429. Expone statusCode y body de respuesta como propiedades, lo que facilita registrar fallas de transporte con contexto completo. Este error indica que el endpoint webhook tuvo un problema, no una situación de rate-limit.
AllWebhooksRateLimitedError extiende SignalClientError con statusCode 429 y expone un array poolStatus. Cada entrada es un snapshot WebhookStatus con index, url, flag available y timestamp rateLimitedUntil. Este error solo se lanza bajo estrategia 'skip' o cuando 'wait' agota su único reintento.
Emisor práctico: Next.js Server Actions
Un patrón de producción común es un panel admin construido con Next.js donde una Server Action actualiza datos de negocio y emite un evento Signal para el bot. Este enfoque mantiene desacopladas las mutaciones de UI y las reacciones del bot, conservando un flujo único para operadores. La Server Action maneja autorización y persistencia dentro de tu aplicación web, y Signal Client maneja la entrega asíncrona hacia el runtime del bot.
'use server';
import { SignalClient } from '@spraxium/signal-client';
const client = new SignalClient({
webhookUrls: [process.env.SIGNAL_WEBHOOK_URL_1!],
hmacSecret: process.env.SIGNAL_HMAC_SECRET!,
rateLimitStrategy: 'wait',
});
export async function updatePrefix(guildId: string, prefix: string) {
// persist in your database first
await db.guilds.update(guildId, { prefix });
// then emit the event for the bot
await client.send('config.update', guildId, {
prefix,
source: 'admin-panel',
});
}Guías de modelado de eventos
Prefiere nombres orientados a dominio que describan hechos de negocio en lugar de acciones de transporte. Un nombre como billing.status.changed es más fácil de versionar y razonar que etiquetas orientadas a acciones que filtran detalles de implementación interna. Mantén los nombres de evento en minúsculas y formato dot-separated para seguir las convenciones usadas en todo el sistema Signal.
Los payloads deberían ser intencionalmente pequeños y estables. Incluye solo los campos que el bot realmente necesita para decidir y evoluciona el schema con cambios aditivos siempre que sea posible. Eliminar o renombrar un campo existente es un cambio breaking que exige coordinar despliegues de emisor y listener al mismo tiempo.
Usa el filtro events en FallbackConfig para enfocar garantías de confiabilidad en los eventos más críticos. Los eventos que no aparecen en esa lista siguen entregándose por la ruta normal, pero no se encolan en fallback store cuando fallan.
Recorrido del flujo de entrega
Construir el envelope firmado
El método send delega en SignalEnvelopeBuilder. El builder genera un
nonce vía crypto.randomUUID(), captura Date.now() como sentAt,
calcula la firma HMAC-SHA256 y devuelve un envelope completo con versión de
protocolo 1.
Elegir el siguiente webhook
WebhookPool.next devuelve el siguiente webhook disponible en orden round-robin. Si se proporcionó SendOptions.webhookIndex, el pool devuelve esa entrada específica vía get, omitiendo rotación. Si todos los webhooks están rate-limited y no se forzó índice, el flujo salta al paso cinco.
Entregar por HTTP POST
El cliente envía un request POST a la URL del webhook con el envelope JSON serializado dentro del campo content de un mensaje de Discord. Si la respuesta es 2xx, la entrega se completa y el método retorna exitosamente.
Manejar respuestas 429
Cuando el webhook devuelve HTTP 429, el pool marca ese índice como
rate-limited por la duración parseada del header Retry-After (con default
de 1000 ms). El cliente prueba inmediatamente el siguiente webhook
disponible. Esta rotación continúa hasta que uno tenga éxito o todos se
agoten.
Aplicar estrategia de rate-limit
Cuando no quedan webhooks, rateLimitStrategy decide el resultado. Con
'skip', se lanza AllWebhooksRateLimitedError de inmediato. Con 'wait',
el cliente espera el cooldown más corto y reintenta una vez. Si fallback
está habilitado y el error es capturable, el envelope se encola en fallback
store para reintento posterior.
Cuándo este modelo es la herramienta equivocada
Si necesitas confirmación síncrona inmediata desde la lógica del bot dentro del mismo ciclo de request, Signal generalmente no es el contrato adecuado. Las llamadas directas a servicios siguen siendo más simples y trazables para comunicación in-process dentro de un mismo límite de servicio. Signal está diseñado para entrega de eventos asíncrona fire-and-forget donde el productor no necesita respuesta del consumidor.
Del mismo modo, si tus eventos requieren garantías de orden estricto, una cola dedicada como RabbitMQ o Redis Streams encaja mejor. Signal entrega mensajes tan rápido como lo permita el pool de webhooks, y el orden tras reintentos por fallas es best-effort, no estrictamente secuencial.