@leparadoxhd/nestjs-hermes
v1.0.6
Published
NestJS module for Hermes — send tasks via BullMQ without exposing queue internals
Readme
@leparadoxhd/nestjs-hermes
Módulo NestJS 11 para enviar tareas a Hermes Server sin tocar BullMQ, carpetas en disco ni job.json. Tu aplicación solo llama a servicios inyectables; el plugin y el worker Hermes se encargan del resto.
Re-exporta los tipos de @leparadoxhd/hermes-client para que puedas importar todo desde un solo paquete.
Qué resuelve este plugin
| Sin Hermes (manual) | Con @leparadoxhd/nestjs-hermes |
| --------------------------------------------- | ------------------------------------------------------ |
| Crear colas BullMQ, backoff, reintentos | Política fija de Hermes (interna) |
| Escribir SHARED_ROOT/<proyecto>/<jobId>/... | image.process() con input en tu disco |
| Encolar con el mismo jobId que en disco | El client genera UUID, disco y Redis |
| Gestionar conexiones Redis por cola | Una conexión Redis compartida creada por el módulo |
Hermes Server (Bun) consume las colas: WebSocket (Socket.IO), email (Gmail), push (Home Assistant) e imágenes (Sharp).
Requisitos
- NestJS 11+
- Redis accesible desde tu app y desde Hermes Server
- Hermes Server en ejecución (workers + Socket.IO)
- Volumen compartido montado en la misma ruta en productor y servidor (
storageen el módulo =SHARED_ROOTen Hermes) - Misma versión de contrato que el servidor (paquete
hermes-clientpublicado junto a este módulo)
Dependencias npm
npm install @leparadoxhd/nestjs-hermes bullmq ioredis| Paquete | Rol |
| ---------------------------- | ------------------------------------------------------------------------------- |
| @leparadoxhd/nestjs-hermes | Módulo + servicios Nest |
| bullmq | Peer — colas (usado por hermes-client dentro del plugin) |
| ioredis | Peer — el plugin crea la instancia; no necesitas new Redis() en tu código |
| @nestjs/common | Peer — Nest 11 |
No necesitas @nestjs/bullmq en tu aplicación.
Instalación rápida
// app.module.ts
import { Module } from "@nestjs/common";
import { HermesModule } from "@leparadoxhd/nestjs-hermes";
@Module({
imports: [
HermesModule.forRoot({
connection: { host: "localhost", port: 6379 },
storage: "/shared",
project: "mi-app",
}),
],
})
export class AppModule {}El módulo es @Global() por defecto (isGlobal !== false): puedes inyectar HermesImageService, HermesEmailService, HermesPushService y HermesWebsocketService en cualquier feature sin reimportar el módulo.
Configuración del módulo
HermesModule.forRoot(options)
| Opción | Tipo | Obligatorio | Descripción |
| ------------ | -------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| connection | string | RedisOptions | Sí | URL (redis://...) u opciones ioredis (host, port, password, db, …). No pases new Redis() — el plugin crea y destruye la conexión. |
| storage | string | Sí | Raíz del volumen compartido (ej. /shared en Docker, misma ruta que ve Hermes Server). |
| project | string | Sí | Slug del proyecto ([a-zA-Z0-9_-]+). Carpetas en disco y namespace WebSocket por defecto. Una app Nest = un proyecto Hermes. |
| prefix | string | No | Prefijo BullMQ (default: hermes). |
| isGlobal | boolean | No | Default true. Pon false si quieres importar HermesModule solo en un submódulo. |
HermesModule.forRootAsync(options)
Misma configuración, cargada desde ConfigService u otro provider:
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { HermesModule } from "@leparadoxhd/nestjs-hermes";
@Module({
imports: [
ConfigModule.forRoot(),
HermesModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
connection: config.getOrThrow<string>("REDIS_URL"),
storage: config.getOrThrow<string>("SHARED_ROOT"),
project: config.getOrThrow<string>("HERMES_PROJECT"),
}),
}),
],
})
export class AppModule {}Variables de entorno sugeridas
REDIS_URL=redis://localhost:6379
SHARED_ROOT=/shared
HERMES_PROJECT=mi-appRedis: gestión automática
- Al arrancar, el módulo crea una instancia ioredis con
maxRetriesPerRequest: null(requerido por BullMQ). - Esa instancia se pasa a las tres colas del
HermesClientinterno (BullMQ modoshared). - Al apagar Nest (
onApplicationShutdown),HermesLifecycle:- cierra colas y
QueueEvents(hermesClient.close()); - hace
quit()en Redis.
- cierra colas y
No debes instanciar Redis en tu app para Hermes. Si pasas un objeto Redis en connection, el módulo lanza un error explicativo.
Caso avanzado: reutilizar la misma conexión
Si otro servicio necesita el mismo Redis que Hermes:
import { Inject, Injectable } from "@nestjs/common";
import { HERMES_SHARED_REDIS } from "@leparadoxhd/nestjs-hermes";
import type { Redis } from "ioredis";
@Injectable()
export class OtroServicio {
constructor(@Inject(HERMES_SHARED_REDIS) private readonly redis: Redis) {}
}Esa conexión la posee el módulo Hermes; no hagas quit() manual salvo que sepas lo que haces.
Servicios inyectables
HermesWebsocketService
Encola un evento Socket.IO en Hermes Server.
import { Injectable } from "@nestjs/common";
import { HermesWebsocketService } from "@leparadoxhd/nestjs-hermes";
@Injectable()
export class NotificacionesService {
constructor(private readonly hermesWs: HermesWebsocketService) {}
async avisar(usuarioId: string) {
await this.hermesWs.add(
{
event: "notification",
payload: { userId: usuarioId, msg: "Hola" },
// namespace opcional; si omites → usa `project` del módulo
// namespace: "otro-namespace",
user: usuarioHash, // opcional → `user:<hash>`; sin user/group → todo el namespace
},
{ wait: false },
);
}
}| Campo payload | Descripción |
| ------------- | ---------------------------------------------------------------------- |
| event | Nombre del evento Socket.IO |
| payload | Datos serializables |
| namespace? | Namespace Socket.IO (/${namespace}). Default: project de forRoot |
| user? | Hash de usuario → room user:<hash> |
| group? | Hash o lista de hashes → rooms group:<hash> |
| (ninguno) | Sin user ni group → broadcast a todo el namespace |
HermesEmailService
Envía correo vía Gmail configurado en Hermes Server (GMAIL_USER / GMAIL_APP_PASSWORD).
const result = await this.hermesEmail.send(
{
to: "[email protected]",
subject: "Asunto",
html: "<p>Cuerpo</p>",
text: "Texto plano opcional",
replyTo: "[email protected]",
},
{ wait: true },
);
if (result.waited && result.success) {
console.log(result.data.messageId);
}Requiere html o text (al menos uno).
HermesPushService
Notificación móvil vía Home Assistant (HA_URL, HA_TOKEN, HA_NOTIFY_DEVICE en el servidor Hermes).
await this.hermesPush.notify(
{ title: "Aviso", message: "Proceso listo" },
{ wait: false },
);Si HA no está configurado, el job termina con { sent: false, skipped: true }. Errores de red/HA no fallan el job.
HermesImageService
Procesa una imagen en el worker Sharp de Hermes. No prepares carpetas ni job.json: solo rutas en tu servidor y operaciones.
import { Injectable } from "@nestjs/common";
import { HermesImageService } from "@leparadoxhd/nestjs-hermes";
@Injectable()
export class AvatarService {
constructor(private readonly hermesImage: HermesImageService) {}
async generarThumbnail(inputPath: string, destinoWeb: string) {
return this.hermesImage.process(
{
input: inputPath,
// o: { folder: "/tmp/uploads", file: "foto.jpg" },
operations: {
resize: { width: 400, fit: "inside", withoutEnlargement: true },
format: "webp",
quality: 80,
stripMetadata: true,
},
output: destinoWeb, // opcional — ver tabla abajo
},
{ wait: true, waitTimeout: 120_000 },
);
}
}input / output
type ImageInput =
| string // ruta absoluta o relativa en el host de tu app
| { folder: string; file: string };- En storage Hermes los nombres son siempre UUID (el client copia tu archivo ahí).
- El worker escribe el resultado en
storage/<project>/<jobId>/output/<uuid>.<ext>. - Si declaras
outputen el payload, tras un job exitoso el client copia del volumen compartido a tu ruta (copiedToen la respuesta). El worker nunca escribe directo en tu filesystem.
| output en payload | Tras wait: true y éxito |
| ------------------- | ------------------------------------------------------ |
| No | Usa result.data.storagePath en el volumen compartido |
| Sí | storagePath + copiedTo (ruta en tu host) |
ImageOperations (contrato worker)
interface ImageOperations {
format?: "jpeg" | "jpg" | "png" | "webp" | "avif" | "gif" | "tiff";
quality?: number;
resize?: {
width?: number;
height?: number;
fit?: "cover" | "contain" | "inside" | "outside" | "fill";
withoutEnlargement?: boolean;
};
shrink?: { quality?: number; effort?: number };
rotate?: number;
flip?: boolean;
flop?: boolean;
grayscale?: boolean;
blur?: number;
sharpen?: number | { sigma?: number };
trim?: boolean | { threshold?: number };
stripMetadata?: boolean;
whiteBackground?: {
transparentUntil?: number;
opaqueFrom?: number;
};
}Ejemplo con fondo blanco → transparencia (útil en miniaturas de comida):
await this.hermesImage.process(
{
input: "/data/foto.jpg",
output: "/static/thumb.webp",
operations: {
whiteBackground: { transparentUntil: 18, opaqueFrom: 55 },
resize: { width: 100, fit: "inside", withoutEnlargement: true },
format: "webp",
quality: 80,
stripMetadata: true,
},
},
{ wait: true },
);Opciones de envío: wait
Segundo argumento común en add, send y process:
interface HermesSendOptions {
wait?: boolean; // default: false
waitTimeout?: number; // ms cuando wait=true; default: 120_000
}Sin wait (fire-and-forget)
const ref = await this.hermesImage.process(payload);
// ref.jobId — string
// ref.waited === falseCon wait: true
Bloquea hasta que el worker termine (o falle por timeout):
type HermesSendResult<TData> =
| { jobId: string; waited: false }
| { jobId: string; waited: true; success: true; data: TData }
| {
jobId: string;
waited: true;
success: false;
error: { message: string; name?: string };
};Tipos de data según cola:
| Servicio | TData (éxito) |
| ---------------------------- | ------------------------------------------------------------------- |
| HermesWebsocketService.add | { namespace?, event, payload, user?, group? } |
| HermesEmailService | { messageId } |
| HermesPushService | { sent, skipped?, error? } |
| HermesImageService | { storagePath, copiedTo?, width, height, size, format, filename } |
Siempre comprueba result.waited y result.success antes de leer data.
Flujo de una imagen (resumen)
sequenceDiagram
participant App as Tu servicio Nest
participant Plugin as nestjs-hermes
participant Disk as SHARED_ROOT
participant Redis as Redis
participant Worker as Hermes Server
App->>Plugin: process(input, operations, wait?)
Plugin->>Disk: jobId UUID + input + job.json
Plugin->>Redis: add process jobId
Worker->>Disk: lee input, escribe output UUID
Worker->>Redis: completed + returnvalue
alt wait true
Plugin->>App: HermesSendResult con data
opt output en payload
Plugin->>Disk: copia output → ruta host
end
else wait false
Plugin->>App: solo jobId
endPolítica de colas (no configurable)
Reintentos y limpieza los define Hermes en el client (no los sobrescribas en tu app):
attempts: 3backoff: { type: "exponential", delay: 1000 }removeOnComplete: trueremoveOnFail: { age: 30 días }
Colas BullMQ: prefijo hermes, nombres websocket, email, image.
Tipos e imports
Todo lo del contrato se puede importar desde @leparadoxhd/nestjs-hermes:
import {
HermesModule,
HermesImageService,
HermesEmailService,
HermesWebsocketService,
type HermesModuleOptions,
type HermesSendOptions,
type HermesSendResult,
type ImageProcessPayload,
type ImageOperations,
type ImageInput,
type EmailSendJob,
} from "@leparadoxhd/nestjs-hermes";Checklist de despliegue
- Hermes Server corriendo con acceso a Redis y
SHARED_ROOT. - Tu app Nest con
storageapuntando al mismo volumen (bind mount / NFS). projectalineado con el slug que esperas en Socket.IO y en rutas de disco.- Redis accesible desde ambos (misma URL que en Hermes).
- Versión de
@leparadoxhd/nestjs-hermescompatible con la del servidor (misma línea que@leparadoxhd/hermes-clientpublicada).
Errores frecuentes
| Síntoma | Causa probable |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Unsupported URL Type "workspace:" | Versión antigua de nestjs-hermes en npm; usa ≥ 1.0.1 con dependencia semver en hermes-client. |
| ERR_PACKAGE_PATH_NOT_EXPORTED | Versión ESM-only antigua; usa ≥ 1.0.2 (build CommonJS). |
| UnknownDependenciesException + HERMES_MODULE_OPTIONS | Versión sin fix DI async; usa ≥ 1.0.3. |
| Job imagen falla / input not found | storage no compartido o archivo no existe antes de process(). |
| WebSocket no llega | namespace distinto al del cliente Socket.IO; por defecto es project. |
| Timeout con wait: true | Subir waitTimeout o optimizar operaciones Sharp. |
Qué no debes hacer
- Pasar
new Redis()enconnection(el plugin lo gestiona). - Crear manualmente
SHARED_ROOT/<project>/<jobId>/nijob.json. - Usar
@InjectQueuede BullMQ para Hermes (este módulo no expone esas colas). - Mezclar varios
projecten una misma app (configura unprojectenforRoot). - Asumir que el worker escribe en
outputde tu host (solo el client copia traswait).
Relacionados
- Servidor: hermes-server
- Cliente framework-agnostic:
@leparadoxhd/hermes-client
Versión
Publica @leparadoxhd/nestjs-hermes con la misma versión que @leparadoxhd/hermes-client cuando cambie el contrato de jobs o ImageOperations.
