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

@knead-js/schedule

v0.6.0

Published

Cron, interval and timeout jobs with distributed locking for Knead, the NestJS-style web framework for Bun

Readme

@knead-js/schedule

npm CI license

Cron, interval and timeout jobs for Knead applications. Decorate methods of ordinary providers, and the scheduler starts them at bootstrap, stops them at shutdown, and can keep each cron firing to one instance across replicas.

Documentation · Guide · Knead

Install

bun add @knead-js/core @knead-js/schedule

Requires Bun 1.4.3 or newer. @knead-js/core is a peer dependency. Cron times are computed by croner (pinned exactly), and RedisLockStore uses Bun's built-in Redis client, so Redis needs no extra package.

Quick start

import { Injectable, inject, Module } from '@knead-js/core';
import { Cron, Interval, type JobContext, ScheduleModule, Timeout } from '@knead-js/schedule';

@Injectable()
export class ReportService {
  readonly built: string[] = [];

  async build(day: Date, signal: AbortSignal): Promise<void> {
    // Pass `signal` to fetch and database calls so shutdown can cancel them.
    signal.throwIfAborted();
    this.built.push(day.toISOString());
  }

  async warmCache(): Promise<void> {}
}

@Injectable()
export class ReportJobs {
  readonly reports = inject(ReportService);

  // 03:00 every day, New York time.
  @Cron('0 3 * * *', { name: 'nightly-report', timezone: 'America/New_York' })
  async nightly({ scheduledAt, signal }: JobContext) {
    await this.reports.build(scheduledAt, signal);
  }

  // Every 30 seconds, starting 30 seconds after startup. Named 'ReportJobs.heartbeat'.
  @Interval('30s')
  heartbeat() {}

  // Once, 5 seconds after startup.
  @Timeout('5s', { name: 'warm-cache' })
  async warmCache() {
    await this.reports.warmCache();
  }
}

@Module({
  imports: [ScheduleModule.forRoot()],
  providers: [ReportService, ReportJobs],
})
export class AppModule {}

ScheduleModule.forRoot() finds the decorated methods on the providers of every module when the application bootstraps and arms their timers. When the application closes, it stops the timers, aborts signal and waits for runs in progress.

Features

  • Three decorators. @Cron(), @Interval() and @Timeout() on instance methods of any singleton class provider.
  • Fails early. Cron patterns, time zones, durations and names are validated when the decorator runs, so a typo fails at import, not at 3 a.m.
  • No drift. Runs are timed from the schedule, not from the end of the previous run.
  • Errors never stop a job. A failing run goes to onError; the default logs a redacted one-line summary, never a raw error object.
  • One instance per firing. With a lock store, a cron job with lock: true runs once per firing across all replicas, and a store outage skips the firing rather than running it twice.
  • Runtime control. SchedulerRegistry lists, pauses, resumes, triggers, adds and removes jobs.
  • Graceful shutdown. Timers stop, signal aborts and shutdown waits up to shutdownTimeout for runs in progress.

Declaring jobs

  • @Cron(pattern, { name?, timezone?, lock? }) runs on a cron pattern: 5 fields, or 6 with seconds first, an optional 7th for the year, and macros such as @daily and @hourly. timezone is an IANA name; the default is the process time zone. Patterns are at most 1024 characters. Across daylight saving time changes, a time in the skipped hour runs an hour later that day, and a time in the repeated hour runs once, in its first occurrence (an instance started during the second occurrence skips it too, so a locked job cannot run twice).
  • @Interval(every, { name? }) runs every every. The first run is one period after startup.
  • @Timeout(after, { name? }) runs once. While that run is in progress the job is listed as stopped; when it settles, the job leaves the registry.

Durations are milliseconds or strings such as '500ms', '30s', '10m', '1h' and '1d', at most one year ('365d').

Every handler receives a JobContext: { name, signal, scheduledAt, trigger }. signal aborts when the application starts shutting down, and trigger is 'schedule' or 'manual'. Handlers are plain method calls, so call inject() in field initializers, never inside the handler.

Rules to keep in mind:

  • Import ScheduleModule.forRoot() once, in the root module. A second import fails at bootstrap instead of running every job twice.
  • A class that no module lists as a provider is never instantiated, so its jobs never run. SchedulerRegistry.list() shows what was registered.
  • Jobs on a transient or multi: true class provider fail at bootstrap (for a multi token, list the class as its own provider and use useExisting). A class provided by several modules registers its jobs once.
  • Job names default to ClassName.methodName and must be unique. Minifiers rename classes, so give jobs explicit names in minified builds.
  • Runs may overlap. A tick starts a new run even if the previous one is still going; guard against that in the job if it matters. Missed firings (a suspended process, a blocked event loop) are not caught up.

Errors

A run that throws or rejects goes to onError(error, ctx) and the job keeps its schedule. The default onError writes one line through logger.error:

[knead:schedule] Job "sync" failed: Error: connect redis://***@cache:6379 refused

That summary masks credentials in URLs and key=value pairs, is capped at 500 characters, and leaves out the stack, cause and other properties. A custom onError receives the raw error, so redact what you log there (summarizeError() from @knead-js/core gives the same summary).

Running a cron job on one instance

By default every process runs every job. With several replicas, give forRoot() a lockStore and set lock on the cron jobs that must run once per firing:

const lockStore = new RedisLockStore({
  url: process.env.REDIS_URL,
  // Refuse a plaintext redis:// URL to a remote host instead of only warning.
  requireTls: true,
});

@Injectable()
export class BillingJobs {
  // Every replica fires at 02:00 UTC; only the one that claims the firing runs it.
  @Cron('0 2 * * *', { name: 'charge-renewals', timezone: 'UTC', lock: true })
  async chargeRenewals() {}

  // A per-job TTL for the claim.
  @Cron('*/5 * * * *', { name: 'retry-failed-charges', lock: { ttl: '2m' } })
  async retryFailedCharges() {}
}

// The scheduler never closes a lock store. onModuleDestroy runs after the scheduler has
// drained its runs in beforeApplicationShutdown.
@Injectable()
class LockStoreLifecycle implements OnModuleDestroy {
  onModuleDestroy() {
    return lockStore.close();
  }
}

@Module({
  imports: [ScheduleModule.forRoot({ lockStore, lockTtl: '90s' })],
  providers: [BillingJobs, LockStoreLifecycle],
})
export class AppModule {}
  • Each firing is claimed under <job name>:<scheduled time>, a key every instance computes the same way. The first instance to claim it runs the job; the others skip that firing.
  • Locks are never released; they expire after the TTL (lockTtl, default 60 seconds, or the job's lock: { ttl }). The TTL must exceed the clock skew and timer lateness between instances, not the job's run time.
  • The lock fails closed. If the store errors or does not answer within commandTimeout (default 250 ms), the firing is skipped and the error goes to onError.
  • A job with lock and no lockStore fails at bootstrap. trigger() ignores locks. Intervals and timeouts run per process and cannot be locked.

Two stores ship with the package: RedisLockStore (one SET key 1 NX PX ttl per firing, shared through Redis or Valkey) and MemoryLockStore (deduplicates only within one process: for tests, or a process that runs several applications).

Securing the Redis connection

  • Use a rediss:// (TLS) URL and a least-privilege ACL user: ACL SETUSER scheduler on >long-random-password resetkeys ~knead:schedule:* -@all +set.
  • A plaintext redis:// or valkey:// URL to a non-loopback host logs a warning without the credentials, and throws ScheduleConfigError with requireTls: true.
  • A client you pass yourself skips that check and is never connected or closed by the store. Create it with enableOfflineQueue: false and a connectionTimeout, as the store does for its own client (ownedRedisClientOptions() from @knead-js/core), so a timed-out command is never sent later, and connect it yourself.

Managing jobs at runtime

Inject SchedulerRegistry to manage jobs while the application runs, for example to schedule one cron job per tenant.

  • addCron(name, pattern, handler, { timezone?, lock? }), addInterval(name, every, handler) and addTimeout(name, after, handler) add a job. Jobs added before bootstrap start with the decorated ones; jobs added later start immediately.
  • remove(name) disarms and forgets a job and returns whether it existed; runs in progress finish. Like every change, it throws ScheduleConfigError after shutdown.
  • list() and get(name) return JobInfo snapshots: name, kind, state, nextRunAt and running (runs in progress).
  • pause(name) disarms a job; resume(name) re-arms it from now, so missed firings do not run.
  • trigger(name) runs a job now, without its lock, and resolves when the run settles.
  • Unknown names throw ScheduleJobNotFoundError. Invalid jobs, duplicate names and changes after shutdown throw ScheduleConfigError.

Options

| Option | Default | Description | | --- | --- | --- | | lockStore | none | Required by any job with lock; bootstrap fails without it. | | lockTtl | '60s' | Lock TTL for jobs with lock: true. | | onError | redacted logger.error | Receives every failed run and every lock store error. | | shutdownTimeout | '10s' | How long shutdown waits for runs in progress before logging their names and continuing. | | enabled | true | false registers every job paused (web-only replicas, tests); trigger() and resume() still work. | | logger | the application's LOGGER | Receives the scheduler's warnings and the default error reports, with control characters in job names escaped. Any Knead Logger fits. |

Options are validated when forRoot() is called, so a mistake throws ScheduleConfigError before the application starts.

Shutdown and testing

Knead runs every beforeApplicationShutdown hook, where the scheduler drains its runs, before any onModuleDestroy hook. Close the connections your jobs use in onModuleDestroy, and jobs finish before those connections close. Call app.enableShutdownHooks() so SIGTERM and SIGINT close the application gracefully.

In tests, pass enabled: false so every job is registered paused, and run jobs with registry.trigger(name), which resolves when the run has settled. Nothing depends on real time.

Documentation

License

Licensed under either of MIT or Apache-2.0 at your option.