pervigil
v0.6.0
Published
Cross-platform keep-awake / prevent-sleep utility for Node — inhibits host & display sleep (caffeinate / systemd-inhibit / SetThreadExecutionState). Zero dependencies, no native addons.
Downloads
474
Maintainers
Readme
pervigil
Latin pervigil: ever-wakeful, keeping watch all night long.
Cross-platform host sleep / display-sleep inhibitor for Node — keeps the machine awake while long jobs run.
- Zero runtime dependencies. No native addons, no
node-gyp— it spawns the OS's own mechanism, so it installs cleanly on Alpine/musl, ARM, Docker and CI with no build toolchain. - Two independent axes — block system sleep and display sleep separately.
- Fail-safe by default — in containers, on unsupported platforms, or when
the OS primitive is missing, it degrades to a silent no-op instead of
throwing, so your job always runs. Opt into fail-fast with
strict, or read.activefor the truth. - Supervised — if the OS primitive dies, it re-engages on the next change.
- Self-cleaning — releases the OS primitive automatically when the process
exits, so a forgotten
release()never leaks an orphanedcaffeinate/systemd-inhibitchild. Opt out withautoRelease: false. - Observable — a
status()snapshot plus counters and events answer "is the host awake, why, on what backend, and for how long?" - Typed & testable — first-class TypeScript, an injectable driver, and a
shipped mock (
pervigil/testing).
| Platform | Mechanism |
| -------- | ---------------------------------------------------------------------------- |
| macOS | caffeinate(1) |
| Linux | systemd-inhibit(1), falling back to /sys/power/wake_lock |
| Windows | SetThreadExecutionState via a spawned PowerShell process (no native addon) |
Every backend uses the same "supervise a long-lived child" pattern, so there is
no native addon and no node-gyp on any platform.
Install
npm install pervigilQuick start
import { keepAwake } from "pervigil";
const lock = await keepAwake({ system: true, description: "nightly backup" });
try {
await runLongJob();
} finally {
await lock.release();
}Scoped variant — auto-releases even if the callback throws:
import { keepAwake } from "pervigil";
await keepAwake.while({ system: true, description: "backup" }, runLongJob);Or with explicit resource management (Node 20.4+ / TS 5.2+):
await using lock = await keepAwake({ display: true, description: "rendering preview" });By default only system sleep is blocked. Pass display: true to also keep
the screen on. And if you ever forget the finally / release(), the lock is
still freed when the process exits — see
Auto-release on process exit.
Timed windows
Hold the lock for a fixed duration, or until a wall-clock instant. The
auto-release runs on an unref()'d timer (so it never keeps the event loop
alive), and an early release() cancels it — it never double-releases:
const lock = await keepAwake.for({ system: true, description: "warm-up" }, 30_000);
const lock2 = await keepAwake.until({ display: true }, new Date(Date.now() + 60_000));Shared instance
When many independent callers each want "keep the host awake", keepAwake.shared()
coalesces them onto one OS primitive — N concurrent holds spawn one primitive,
not N. Each handle's release() removes only its own hold; call
keepAwake.shutdownShared() to tear the shared primitive down (e.g. on exit):
const a = await keepAwake.shared({ description: "task a" });
const b = await keepAwake.shared({ description: "task b" }); // reuses a's primitive
await a.release();
await b.release();
await keepAwake.shutdownShared();shared() takes only the per-call axes (system / display / description) —
not controller config — so the shared instance is always default-configured
(no first-caller-wins surprise). If you need a custom identity / logger /
strict / injected driver, create your own wakeLock() and share the reference.
Auto-release on process exit
You don't need to do anything for this. By default, every lock releases its OS
primitive when the process exits — so a forgotten release() / shutdown()
can't leave an orphaned caffeinate / systemd-inhibit / PowerShell child
keeping the machine awake. It's wired with a single shared process "exit"
handler (never a SIGINT/SIGTERM listener, so it can't interfere with Ctrl-C
or your own signal handling), covering normal exit, process.exit(), and Ctrl-C.
Opt out per lock with autoRelease: false if you want to own teardown entirely:
const wl = wakeLock({ autoRelease: false });The default covers normal exit, process.exit(), and Ctrl-C. The one case it
can't is a SIGTERM delivered straight to the Node process — that bypasses the
exit event, and a library can't safely install a SIGTERM handler by default
without fighting your app's own shutdown logic. In practice it rarely bites:
under systemd / containers the whole process group is signalled, so the child
dies with it. For a bare kill <pid> daemon that needs it, opt in with
releaseOnExit — it releases on SIGINT / SIGTERM and then re-raises the
signal so the process still terminates normally:
import { wakeLock, releaseOnExit } from "pervigil";
const wl = wakeLock();
const stop = releaseOnExit(wl); // also shut down on SIGINT / SIGTERM
// ... later, if you take over signal handling yourself:
stop();Behaviour: fail-safe by default
pervigil is fail-safe: by default it never throws. When it can't keep the host awake it degrades to a silent no-op and your job still runs. It no-ops when:
- running inside a container (
/.dockerenv, thecontainerenv var, or/run/.containerenv); - on a platform with no inhibitor backend (e.g. FreeBSD), or when the platform
binary (
caffeinate/systemd-inhibit/ PowerShell) is missing; - forced off via
PERVIGIL_FORCE_NOOP=1orforceNoop: true.
The catch: "I called keepAwake" is not the same as "the host is awake."
Three status fields separate the three questions:
| Field | Question |
| ----------- | -------------------------------------------------------------- |
| available | Is the driver capable of a real primitive? (false ⇒ no-op) |
| engaged | Is a reason desired on this axis? (intent) |
| active | Is a real OS assertion in effect right now? (reality) |
active is the truth — it's false when degraded, when nothing is held, or
when the OS primitive died and hasn't re-engaged yet, cases that available and
engaged can't distinguish.
Stay fail-safe, but check .active:
const lock = await keepAwake({ description: "backup" });
if (!lock.status().active) {
log.warn("running without a wake lock — host may sleep");
}Or opt into fail-fast with strict — when the job must not run unless the
host is genuinely kept awake, pervigil throws WakeLockUnavailableError instead
of no-op'ing:
import { keepAwake, WakeLockUnavailableError } from "pervigil";
try {
await keepAwake({ description: "backup", strict: true });
} catch (err) {
if (err instanceof WakeLockUnavailableError) {
console.error(`can't keep awake: ${err.degradedReason} on ${err.platform}`);
process.exit(1);
}
}strict is opt-in; the default stays a graceful, never-throwing no-op. For
long-running locks, watch the degraded / primitiveDied events (or onEvent)
to learn when active flips to false.
Supervised, multi-reason controller
For daemons that hold several overlapping reasons, use wakeLock. It
reconciles reasons (by key) onto the two axes and drives one OS primitive:
import { wakeLock } from "pervigil";
const wl = wakeLock({ identity: "my-app" }); // identity shows in `systemd-inhibit --list`
wl.acquire("job:123", { system: true, description: "import job 123" });
wl.acquire("view:abc", { display: true, description: "live view abc" });
wl.release("job:123"); // system axis releases; display stays held
await wl.shutdown(); // release everything, tear down the primitiveIf you already recompute the desired set each tick, apply replaces the
entire held set in one reconcile (the declarative, batch sibling of
acquire/release — same per-axis defaults, idempotent, one OS flush):
wl.apply([
{ key: "job:123", system: true, description: "import job 123" },
{ key: "view:abc", display: true, description: "live view abc" },
]);
// any previously-held key absent from this set is released, in a single flushSelf-managing locks (supervisor)
pervigil/supervisor is a declarative layer over wakeLock: declare a
condition and forget it. Each lock states its impact (axes), an optional
active predicate ("should it hold right now?"), and optional eviction triggers
(until / maxAge / stale). The supervisor polls these, reconciles the
underlying wake lock, and reaps dead locks — so you never manually
acquire/release or track lifetimes:
import { supervise } from "pervigil/supervisor";
const sup = supervise({ identity: "my-app", poll: "auto" });
// conditional / standing — held while any download is running
sup.add({ key: "downloads", description: "Active downloads", active: () => downloads.size > 0 });
// scoped / one-time — held for one operation, auto-evicted when it settles
sup.add({ description: "Importing dataset", until: importDataset() });
// also keep the display awake while a preview is on screen
sup.add({
key: "preview",
axes: ["system", "display"],
description: "Live preview",
active: () => preview.visible,
});
// pinned override — held until you remove it
const override = sup.add({ axes: ["system", "display"], description: "Presentation mode" });
// granular control + introspection
sup.list().forEach((l) => console.log(l.key, l.state)); // holding | idle | unknown | paused | evicted
sup.get("downloads")?.pause();
sup.restrict("display"); // veto an axis — never engage the display, whatever the locks say
override.remove();
await sup.shutdown();Lock shapes:
- Conditional / standing —
{ active }auto-engages/disengages; lives until removed. - Scoped / one-time —
{ until }(a promise or a predicate) held until it settles, then evicted. - Pinned — neither; held while registered.
The state machine is surfaced on every handle (holding, idle, unknown,
paused, evicted). If an active predicate throws, the lock is engaged
defensively (unknown, fail-awake) so the host can recover, and the
stale clock starts — keep erroring for stale ms (5 min by default) and the
lock is evicted; any successful evaluation resets it. maxAge is an opt-in hard
ceiling from registration. poll is number (ms), "auto" (60s, and no
timer at all when no lock needs polling — all pinned / promise-until), or
() => number; the timer is always unref()'d, so it never keeps the process
alive. refresh() forces an immediate re-reconcile.
Observability
const wl = wakeLock();
wl.on("engaged", (s) => console.log("awake:", s.reasons));
wl.on("degraded", (s) => console.warn("no-op:", s.degradedReason));
wl.status();
// {
// platform: "macos-caffeinate",
// available: true, // the driver is capable
// active: true, // a real assertion is in effect RIGHT NOW
// degradedReason: null,
// engaged: { system: true, display: false }, // intent (a reason is desired)
// reasons: { system: [{ key: "job:123", description: "import job 123" }], display: [] },
// since: { system: 1718900000000, display: null },
// counters: {
// engageTransitions: { system: 1, display: 0 },
// awakeMsTotal: { system: 42000, display: 0 },
// primitiveRestarts: 0,
// },
// }Events: engaged, disengaged, reasonsChanged, primitiveDied, degraded.
For telemetry, onEvent is a single hook that fires on every event with a
fresh snapshot — wire it once at construction instead of subscribing to each:
const wl = wakeLock({
onEvent: (event, status) => metrics.record(event, status), // OTel/StatsD/logs
});A throwing onEvent is swallowed, so a flaky telemetry backend can never break
the lock.
At the OS level you can also see the assertion directly — pmset -g assertions
on macOS, or systemd-inhibit --list (look for your identity) on Linux.
The identity you pass to wakeLock surfaces the assertion's owner on
Linux (systemd-inhibit --who=, or the sysfs wake-lock cookie) and tags it on
Windows, but has no effect on macOS — caffeinate(1) exposes no equivalent, so
the value is silently ignored there.
Metrics
pervigil/metrics turns the cumulative counters into Prometheus text or a
neutral sample list — no prom-client dependency, so you can adapt the
samples to OpenTelemetry, StatsD, JSON, or anything else:
import { wakeLock } from "pervigil";
import { toPrometheus, collectMetrics } from "pervigil/metrics";
const wl = wakeLock();
// Expose a /metrics endpoint (any HTTP framework):
res.setHeader("content-type", "text/plain; version=0.0.4");
res.end(toPrometheus(wl));
// …or adapt the neutral samples to another backend:
for (const s of collectMetrics(wl)) {
myBackend.record(s.name, s.value, { type: s.type, ...s.labels });
}Emits pervigil_available, pervigil_active, pervigil_awake{axis},
pervigil_awake_ms_total{axis}, pervigil_engage_transitions_total{axis}, and
pervigil_primitive_restarts_total. (pervigil_active is the "is the host
actually awake right now" gauge — the one to alert on.)
OpenTelemetry
collectMetrics() adapts to OpenTelemetry without pervigil depending on the OTel
SDK — you bring @opentelemetry/api and read the samples from an observable
callback:
import { metrics } from "@opentelemetry/api";
import { wakeLock } from "pervigil";
import { collectMetrics } from "pervigil/metrics";
const wl = wakeLock();
const meter = metrics.getMeter("pervigil");
for (const name of ["pervigil_awake", "pervigil_awake_ms_total"]) {
meter.createObservableGauge(name).addCallback((result) => {
for (const s of collectMetrics(wl).filter((m) => m.name === name)) {
result.observe(s.value, s.labels);
}
});
}Testing
A deterministic in-memory driver lives at pervigil/testing so it never ships
in production bundles:
import { wakeLock } from "pervigil";
import { MockDriver } from "pervigil/testing";
const driver = new MockDriver();
const wl = wakeLock({ driver });
await wl.acquire("job", { system: true });
expect(driver.engageTransitions).toBe(1);Logging
pervigil is silent by default — it never writes to your console unless you opt in. Turn it on with a level, either per-call or via the environment:
const wl = wakeLock({ logLevel: "warn" }); // built-in console sinkPERVIGIL_LOG_LEVEL=debug node app.jsLevels are silent | warn | info | debug. Resolution is logLevel option
→ PERVIGIL_LOG_LEVEL → default (silent). At warn you see degraded-mode
warnings (container, missing binary, unsupported platform); info adds the
selected-backend line; debug adds per-assertion detail.
Prefer your own logger? Pass a logger and pervigil routes through it instead
of the console. Two shapes are accepted:
A method sink — any object with a warn method (and optional info /
debug), called (fields, msg). pino, bunyan, roarr, and console drop in
directly:
import pino from "pino";
const wl = wakeLock({ logger: pino() }); // forwards everything; pino filtersA function sink — one (record) => void callback per line, so loggers with
a different argument order (winston, consola, …) map cleanly. record.fields is
always an object ({} when there's no structured data):
const wl = wakeLock({
logger: (r) => winston.log(r.level, r.msg ?? "", r.fields),
});A LogRecord is { level: "warn" | "info" | "debug"; msg?: string; fields: object }.
logLevel: "silent" hard-mutes either kind of supplied logger.
License
MIT © Oleksandr Zhuravlov
