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

@nuetzliches/croniq-runner

v0.9.0

Published

Croniq runner SDK for Node.js. Polls a Croniq server for work, dispatches handlers, streams logs back.

Readme

Croniq Runner SDK for Node.js

npm License: MIT OR Apache-2.0

Build job execution runners for Croniq in Node.js / TypeScript. The SDK polls a Croniq server for work, dispatches handlers, streams structured logs back, and reports completion — using native fetch and AbortController, no extra HTTP dependency.

Aimed at workloads that are Node-native: headless-browser automation (Playwright / Puppeteer), web scraping, npm-ecosystem builds, anything that lives most naturally in a JS toolchain.

Install

npm install @nuetzliches/croniq-runner

(The package name is org-scoped under @nuetzliches to align with the project's other npm artefacts. Import path: import { createRunner } from '@nuetzliches/croniq-runner';.)

Requires Node.js ≥ 22.12 (ESM, native fetch, AbortController).

Quick start

import { createRunner } from '@nuetzliches/croniq-runner';

const runner = createRunner({
  serverUrl: 'http://localhost:4000',
  apiKey: process.env.CRONIQ_API_KEY,
  capabilities: ['demo'],
  tags: ['env=dev', 'lang=typescript'],
  maxInflight: 5,
});

runner.handle('hello:world', async (ctx) => {
  ctx.logger.info(`Hello from ${ctx.jobKey} (attempt ${ctx.attempt})`);
  await new Promise((r) => setTimeout(r, 1_000));
});

// Wire SIGTERM/SIGINT to graceful shutdown.
const controller = new AbortController();
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
  process.on(sig, () => controller.abort());
}

await runner.run(controller.signal);

Features

  • ESM-first, native fetch + AbortController — no axios, no node-fetch shim.
  • Two handler styles: delegate (runner.handle('key', fn)) and a default catch-all (runner.setDefaultHandler(fn)).
  • Server-side cancellation — PollResponse.cancel aborts the handler's ctx.signal.
  • Streaming LogWriter — bounded queue with backpressure, batch by count (32) / by time (200 ms) / max 100 events per POST, drain-before-ack.
  • Self-registration — runner.handle('key', fn, { schedule: '5m' }) calls POST /v1/jobs/register once at startup (DSL precedence applies server-side).
  • Producer trigger client — createTriggerClient(...) fires jobs on demand via POST /v1/trigger with its own jobs:trigger credentials, idempotency_key dedup, and 429 backpressure surfaced as QueueOverflowError. See Trigger a job on demand.
  • Auth precedence — Authorization: ApiKey {key} if set, else Bearer {token}.
  • Persistent runner-id — env var → state file → generated {prefix}-{hex8}, persisted under $XDG_STATE_HOME/croniq-runner (Linux/macOS) or %LOCALAPPDATA%\croniq-runner (Windows).
  • Ack retry — a final ack that fails on a transport error, a request timeout or a 5xx is sent again (three attempts, about 1 s and 2 s apart); a 4xx is not retried.
  • No OpenTelemetry integration (yet) — unlike the Go, Python, .NET and Java SDKs this one emits no span per execution and does not depend on @opentelemetry/api. Wrap your handler in your own span if you need one.

Capabilities vs Tags

Same rule as the .NET / Rust SDKs — don't put implementation details into capabilities. Capabilities drive routing (require / prefer in the Croniqfile); tags are filter-only.

| Good capability | Bad capability | | -------------------------------------------- | ----------------------------- | | billing, reporting, gpu, sandboxed | nodejs, typescript, linux-x64 |

If your runner is Node-based, put that into tags (lang=typescript, platform=linux-x64) so a future Go or Python runner with the same business capabilities can take over without rewriting Croniqfile entries.

Handler API

The handler receives an ExecutionContext:

import type { ExecutionContext } from '@nuetzliches/croniq-runner';

runner.handle('billing:invoice', async (ctx: ExecutionContext) => {
  const meta = ctx.metadata as { customer_id: string };

  // Structured logger pre-scoped with execution_id, job_key, runner_id, attempt.
  ctx.logger.info('generating invoice', { customer_id: meta.customer_id });

  // Streaming events visible in the Croniq UI execution log:
  for await (const line of generateInvoice(meta.customer_id, ctx.signal)) {
    await ctx.logWriter.write('info', line);
  }

  // ctx.signal aborts when the server cancels OR the runner drain-timeout fires.
  if (ctx.signal.aborted) return;
});

ctx.metadata is the raw server-provided JSON — cast to your expected shape. Field names are the original snake_case sent by the server.

Streaming logs

runner.handle('export:report', async (ctx) => {
  const writer = ctx.logWriter;
  for (let i = 0; i < 100; i++) {
    await writer.write('info', `processing row ${i}`, { row: String(i) });
  }
  // No explicit flush() needed — the runner drains the writer before ack.
  // Call writer.flush() if you need a synchronization barrier mid-handler.
});

The writer batches by count and time and survives transient POST failures (drops the batch, logs a warning, keeps going). On per-execution shutdown the runner drains queued events with a configurable timeout (logWriter.shutdownTimeoutMs, default 5 s) — late events never arrive after the ack.

Self-register a schedule

runner.handle(
  'reports:daily',
  async (ctx) => { /* … */ },
  { schedule: '1h', timeout: '10m', description: 'rolling 24-hour digest' },
);

On startup the SDK calls POST /v1/jobs/register. If a Croniqfile (DSL) entry with the same job_key exists, the server returns status=skipped_dsl_precedence and the SDK logs an info message — your runner still polls and executes the job normally, just driven by the DSL schedule rather than the runner-registered one.

Trigger a job on demand (producer)

The runner above is the consumer side. The producer side — firing a job immediately, e.g. in response to an application event — is a separate, first-class client wrapping POST /v1/trigger. It is independent of the runner: triggering needs the jobs:trigger (or admin) scope, which runner poll keys typically don't carry, so the trigger client takes its own credentials rather than reusing a runner's. (Parity with the .NET SDK's ICroniqTriggerClient.)

import { createTriggerClient } from '@nuetzliches/croniq-runner';

const client = createTriggerClient({
  serverUrl: 'http://localhost:4000',
  apiKey: process.env.CRONIQ_TRIGGER_KEY, // jobs:trigger scope — NOT a runner poll key
});

const { executionId, queued, deduplicated } = await client.trigger('billing:invoice-generate', {
  metadata: { invoice_id: 'inv_42' },
  require: ['billing'],
  prefer: ['eu-central'],
  timeout: '10m',
  idempotencyKey: 'evt-2026-07-14-001', // optional server-side dedup
});

The same registered handler serves both its Croniqfile schedule (safety-net / reconcile floor) and near-real-time, event-driven fires — one execution and observability path, no second code path.

  • Unset optionals are omitted from the JSON body — a producer never sends metadata / require / prefer / timeout / idempotency_key the caller didn't supply.
  • Idempotency. Pass idempotencyKey so at-least-once producers (event redelivery, retries, concurrent publishers) coalesce onto one execution. The result's deduplicated is true when the server returned an existing execution; servers without idempotency support omit the flag and it defaults to false.
  • Backpressure. A 429 (per-job queue-overflow cap) throws QueueOverflowError — a subclass of HttpError carrying retryAfterMs when the server sends Retry-After — so a batching / retrying producer can back off instead of dropping work. Any other non-2xx throws HttpError.

| Option | Default | Description | | ------------------ | -------------- | ---------------------------------------------------- | | serverUrl | (required) | Croniq server base URL. HTTPS required off loopback. | | apiKey | — | Authorization: ApiKey {…}. Precedence over bearer. | | bearerToken | — | Authorization: Bearer {…}. | | requestTimeoutMs | 30_000 | Per-request timeout. | | fetchImpl | global fetch | Custom fetch (testing / proxies). | | allowInsecureHttp| false | Opt in to cleartext http:// off loopback. | | logger | console (warn) | Sink for SDK diagnostics. |

Configuration

| Option | Default | Description | | --------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------ | | serverUrl | (required) | Croniq server base URL. HTTPS required off loopback — see below. | | allowInsecureHttp | false | Opt in to a cleartext http:// serverUrl on a non-loopback host. | | runnerId | resolved | Stable id. Falls back to RUNNER_ID env var → state file → generated. | | runnerIdPrefix | "runner" | Used when generating an id. | | runnerDataDir | platform default | Where the persistent runner-id file lives. | | apiKey | — | Authorization: ApiKey {…}. Takes precedence over bearerToken. | | bearerToken | — | Authorization: Bearer {…}. | | capabilities | [] | Routing capabilities. | | tags | [] | Free-form key=value tags. Filter-only. | | maxInflight | 5 | Max concurrent in-flight executions. | | pollTimeoutMs | 35_000 | Per-request timeout on the long-poll. | | requestTimeoutMs | 30_000 | Per-request timeout on ack, renew, log events and job registration. | | renewIntervalMs | 15_000 | Heartbeat cadence for in-flight executions. | | drainTimeoutMs | 30_000 | Graceful shutdown budget before hard-cancel. | | pollRetryDelayMs | 5_000 | Back-off after a failed poll. | | capacityBackoffMs | 500 | Idle wait when at maxInflight. | | maxConsecutivePollConflicts | 3 | Consecutive poll 409s before PollInstanceConflictError. | | logWriter | see below | Streaming log-writer tunables. |

LogWriter sub-options: channelCapacity (256), batchSizeThreshold (32), batchTimeThresholdMs (200), maxBatchPerPost (100), shutdownTimeoutMs (5_000).

Transport security

The credential is attached to every request as an Authorization header. Over http:// it travels in cleartext — and undici honours HTTP_PROXY by default, so it can traverse an intermediary in the clear too.

Both createRunner and createTriggerClient therefore refuse a cleartext serverUrl at construction time unless the host is loopback:

  • accepted: any https:// URL, and http:// on localhost, 127.0.0.0/8 or ::1 — so the http://localhost:4000 quickstart default keeps working;
  • refused: http:// on any other host, with a TypeError naming the URL and the opt-in.

If a deployment genuinely has no TLS terminator (a lab or staging box), opt in explicitly — the SDK then starts, but emits one loud warning through the configured logger:

const runner = createRunner({
  serverUrl: 'http://croniq.internal:4000',
  apiKey: process.env.CRONIQ_API_KEY,
  allowInsecureHttp: true, // the API key travels in cleartext
});

Wire-protocol conformance

Validated against the shared, language-neutral suite at sdks/conformance/ — the runner (consumer) cases in cases/, plus the producer (trigger) cases in cases-trigger/ (#287) which the binding's trigger runner picks up automatically once present. The TypeScript binding lives at sdks/conformance/bindings/typescript/.

cd sdks/conformance/bindings/typescript
npm install
npm test

Releasing

Publishing is fully automated by typescript-sdk-release.yml. To ship a new version:

  1. Bump version in package.json and update the CHANGELOG.
  2. Merge to main.
  3. Tag the commit: git tag ts-sdk-v0.2.0 && git push --tags.

The workflow re-runs typecheck, lint, unit tests, build, and the full conformance suite against the freshly built artefact, then npm publish --provenance --access public. Pre-release tags (anything with a -, e.g. ts-sdk-v0.2.0-rc.1) publish under the next npm dist-tag so npm install @nuetzliches/croniq-runner keeps pointing at the latest stable.

Prerequisite: a repo admin must set the NPM_TOKEN secret (an npm automation or granular token with publish rights to the @croniq scope) once.

Compatibility matrix

| SDK Version | Node.js | Croniq Server (min) | Croniq Server (max tested) | | ----------- | ----------- | ------------------- | -------------------------- | | 0.1.x | ≥ 18.0.0 | 0.14.0 | 0.14.0 |

License

Dual-licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.