A backend-agnostic background job processing library for TypeScript. Define jobs as classes, enqueue them from anywhere, and process them with pluggable backends like Deno KV or Redis (BullMQ).
- Backend-agnostic: Swap between Deno KV and Redis/BullMQ (or write your own adapter)
- Job classes: Encapsulate job logic in typed, reusable classes
- Manifest-based registration: Auto-discover jobs from a single manifest file
- Delayed jobs: Schedule jobs to run after a specified delay
- Multi-queue support: Route jobs to different queues and process them independently
- Structured logging: JSON-formatted lifecycle events for every job
- Graceful shutdown: Clean worker shutdown with configurable timeouts
- Deno Deploy compatible: Works out of the box on Deno Deploy with the Deno KV backend
# From JSR
deno add @dafu/hermesOr import directly in your deno.json:
{
"imports": {
"@dafu/hermes": "jsr:@dafu/hermes"
}
}Create a job class that extends the base Job class. Each job must declare a
unique jobName and a queueName:
// jobs/email_job.ts
import { Job } from "@dafu/hermes";
interface EmailPayload {
to: string;
subject: string;
body: string;
}
export class EmailJob extends Job {
jobName = "send_email";
queueName = "emails";
async perform(jobBody: unknown): Promise<void> {
const { to, subject, body } = jobBody as EmailPayload;
console.log(`Sending email to ${to}: ${subject}`);
// Your email sending logic here
}
}Export all your job classes as an array. Hermes supports both default and named
jobs exports:
// jobs/main.ts
import { EmailJob } from "./email_job.ts";
import { ReportJob } from "./report_job.ts";
// Default export
export default [EmailJob, ReportJob];
// OR named export
// export const jobs = [EmailJob, ReportJob];Choose a backend and point the worker at your manifest:
// worker.ts
import { DenoKvBackend, Hermes } from "@dafu/hermes";
const hermes = Hermes({
manifest: "./jobs/main.ts",
backend: DenoKvBackend(),
});
await hermes.start();
console.log("Worker is running");
// Graceful shutdown
Deno.addSignalListener("SIGINT", async () => {
await hermes.stop();
Deno.exit(0);
});Before enqueueing, you must configure the backend. You can either start a full
Hermes instance or use configure() for enqueue-only processes:
// enqueue.ts
import { configure, DenoKvBackend } from "@dafu/hermes";
import { EmailJob } from "./jobs/email_job.ts";
// Configure the backend (required before calling performLater)
configure({ backend: DenoKvBackend() });
const job = new EmailJob();
await job.performLater({
to: "user@example.com",
subject: "Welcome!",
body: "Thanks for signing up!",
});
console.log("Job enqueued");Hermes ships with two built-in backends. You can also implement the
BackendAdapter interface to create your own.
Zero-configuration backend using Deno's built-in KV store. Works on Deno Deploy out of the box.
import { DenoKvBackend } from "@dafu/hermes";
// Default (uses Deno's default KV store)
const backend = DenoKvBackend();
// Custom KV path (local development)
const backend = DenoKvBackend({ path: "./my-data.sqlite" });Run workers with the --unstable-kv flag (not needed on Deno Deploy):
deno run --unstable-kv worker.tsProduction-grade backend powered by BullMQ. Supports concurrency, retries, and all BullMQ features. Requires a running Redis instance.
import { BullMQBackend } from "@dafu/hermes/backends/bullmq";
const backend = BullMQBackend({
connection: {
host: "localhost",
port: 6379,
// password: "secret",
},
concurrency: 5, // Process up to 5 jobs concurrently per queue
defaultQueueName: "default", // Fallback queue name
});deno run worker.tsImplement the BackendAdapter interface to use any queue system:
import type { BackendAdapter, EnqueueOptions } from "@dafu/hermes";
import type { JobPayload } from "@dafu/hermes";
class MyCustomBackend implements BackendAdapter {
async enqueue(payload: JobPayload, options?: EnqueueOptions): Promise<void> {
// Add the job to your queue system
}
async listen(
handler: (payload: JobPayload) => Promise<void>,
options?: { queueNames?: string[] },
): Promise<void> {
// Start consuming jobs and call handler() for each one
}
async close(): Promise<void> {
// Clean up connections
}
}Define jobs that run on a schedule using every (interval) or cron (expression) properties. Recurring jobs are registered automatically when hermes.start() is called.
Use [number][unit] format where unit is s (seconds), m (minutes), h (hours), or d (days):
export class HealthCheckJob extends Job {
jobName = "health_check";
queueName = "default";
every = "5m"; // Run every 5 minutes
async perform(): Promise<void> {
console.log("Running health check...");
}
}Use standard 5 or 6 field cron expressions:
export class DailyReportJob extends Job {
jobName = "daily_report";
queueName = "reports";
cron = "0 9 * * 1-5"; // 9 AM Monday-Friday
async perform(): Promise<void> {
console.log("Generating daily report...");
}
}| Feature | Deno KV | BullMQ (Redis) |
|---|---|---|
every support |
Minutes and above (m, h, d) |
All units including seconds (s) |
cron support |
Yes | Yes |
| Deduplication | Automatic via Deno.cron |
Automatic via upsertJobScheduler |
| Overlap prevention | Built-in | Built-in |
Note: Seconds-level granularity (
s) is only supported on BullMQ. Deno KV's minimum granularity is 1 minute sinceDeno.cronhas no seconds field.
Schedule a job to execute after a delay (in milliseconds):
const job = new EmailJob();
// Send the welcome email 5 minutes from now
await job.performLater(
{ to: "user@example.com", subject: "Welcome!", body: "Hi!" },
{ delay: 5 * 60 * 1000 },
);Jobs declare which queue they belong to via queueName. This lets you run
specialized workers that only process specific queues, or a single worker that
handles everything.
// A high-priority job
export class PaymentJob extends Job {
jobName = "process_payment";
queueName = "payments";
async perform(jobBody: unknown): Promise<void> {
// ...
}
}
// A low-priority job
export class ReportJob extends Job {
jobName = "generate_report";
queueName = "reports";
async perform(jobBody: unknown): Promise<void> {
// ...
}
}Hermes automatically extracts all unique queue names from the manifest and listens on each one. With the BullMQ backend, each queue gets its own dedicated BullMQ Worker for true multi-queue processing.
Creates a Hermes instance. Returns an object with start() and stop()
methods.
const hermes = Hermes({
manifest: "./jobs/main.ts", // Path to your jobs manifest file
backend: DenoKvBackend(), // A BackendAdapter instance
worker: {
gracefulShutdownTimeout: 10000, // Shutdown timeout in ms (default: 30000)
},
});
await hermes.start(); // Load manifest, register jobs, start worker
await hermes.stop(); // Gracefully shut downSets the global backend for enqueuing jobs without starting a worker. Use this in processes that only enqueue (e.g., a web server):
import { configure, DenoKvBackend } from "@dafu/hermes";
configure({ backend: DenoKvBackend() });
// Now you can call job.performLater() anywhere in this processBase class for all jobs.
| Property / Method | Type | Description |
|---|---|---|
jobName |
string (abstract) |
Unique identifier for the job type |
queueName |
string (abstract) |
Queue this job is dispatched to |
every |
string? |
Interval schedule, e.g. "5m", "1h", "7d" |
cron |
string? |
Cron expression, e.g. "0 9 * * 1-5" |
perform(jobBody) |
Promise<unknown> (abstract) |
The work the job does |
performLater(jobBody?, opts?) |
Promise<void> |
Enqueue the job for async processing |
isRecurring() |
boolean |
Whether the job has a recurring schedule |
| Option | Type | Default | Description |
|---|---|---|---|
path |
string |
undefined |
Custom path for the KV store file |
| Option | Type | Default | Description |
|---|---|---|---|
connection.host |
string |
undefined |
Redis host |
connection.port |
number |
undefined |
Redis port |
connection.password |
string |
undefined |
Redis password |
connection.url |
string |
undefined |
Redis connection URL |
concurrency |
number |
1 |
Max concurrent jobs per queue worker |
defaultQueueName |
string |
"default" |
Fallback queue when none is specified |
// The payload structure sent through the queue
type JobPayload = {
jobName: string;
queueName: string;
jobBody: unknown;
metadata?: Record<string, unknown>;
};
// Options for performLater
type PerformLaterOptions = {
delay?: number; // Delay in milliseconds
};
// Worker configuration
type WorkerConfig = {
concurrency?: number;
gracefulShutdownTimeout?: number; // Default: 30000ms
};
// Main configuration
type HermesParams = {
manifest: string; // Path to the manifest file
backend: BackendAdapter; // Backend instance
worker?: WorkerConfig;
};Hermes emits structured JSON logs for all job lifecycle events:
{"timestamp":"2026-01-15T12:00:00.000Z","event":"worker_started","registeredJobs":3,"config":{"queueNames":["emails","reports"]}}
{"timestamp":"2026-01-15T12:00:01.000Z","event":"job_received","jobName":"send_email","queueName":"emails"}
{"timestamp":"2026-01-15T12:00:01.001Z","event":"job_started","jobName":"send_email","queueName":"emails"}
{"timestamp":"2026-01-15T12:00:01.050Z","event":"job_succeeded","jobName":"send_email","queueName":"emails","durationMs":49}| Event | Description |
|---|---|
worker_started |
Worker is listening for jobs |
job_received |
A job payload was dequeued |
job_started |
Job perform() is being called |
job_succeeded |
Job completed without errors |
job_failed |
Job threw an error (includes error message and duration) |
job_skipped |
Job was skipped (e.g., queue filtering) |
unknown_job |
Received a job with an unregistered jobName |
recurring_job_registered |
A recurring job schedule was registered at startup |
recurring_job_skipped |
A recurring job schedule registration was skipped |
worker_stopping |
Shutdown signal received |
worker_stopped |
Worker has fully shut down |
- Duplicate
jobName: Hermes throws at startup if two job classes share the samejobName. - Invalid manifest: Throws if the manifest file does not export an array via
defaultorjobs. - Manifest not found: Throws with a clear message if the manifest path is wrong.
- Unknown job: If a queued message references an unregistered
jobName, the worker logs anunknown_jobevent and skips it. - Job execution failure: If
perform()throws, the error is logged with thejob_failedevent (including duration) and re-thrown to the backend, which can handle retries if supported.
The repository includes working examples for both backends:
# Deno KV backend
deno task worker # Start the worker
deno task enqueue # Enqueue a job
# Redis/BullMQ backend (requires a running Redis instance)
deno task worker:redis # Start the worker
deno task enqueue:redis # Enqueue a jobdeno task testMIT