@nuetzliches/croniq-runner
v0.9.0
Published
Croniq runner SDK for Node.js. Polls a Croniq server for work, dispatches handlers, streams logs back.
Maintainers
Readme
Croniq Runner SDK for Node.js
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— noaxios, nonode-fetchshim. - Two handler styles: delegate (
runner.handle('key', fn)) and a default catch-all (runner.setDefaultHandler(fn)). - Server-side cancellation —
PollResponse.cancelaborts the handler'sctx.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' })callsPOST /v1/jobs/registeronce at startup (DSL precedence applies server-side). - Producer trigger client —
createTriggerClient(...)fires jobs on demand viaPOST /v1/triggerwith its ownjobs:triggercredentials,idempotency_keydedup, and429backpressure surfaced asQueueOverflowError. See Trigger a job on demand. - Auth precedence —
Authorization: ApiKey {key}if set, elseBearer {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_keythe caller didn't supply. - Idempotency. Pass
idempotencyKeyso at-least-once producers (event redelivery, retries, concurrent publishers) coalesce onto one execution. The result'sdeduplicatedistruewhen the server returned an existing execution; servers without idempotency support omit the flag and it defaults tofalse. - Backpressure. A
429(per-job queue-overflow cap) throwsQueueOverflowError— a subclass ofHttpErrorcarryingretryAfterMswhen the server sendsRetry-After— so a batching / retrying producer can back off instead of dropping work. Any other non-2xx throwsHttpError.
| 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, andhttp://onlocalhost,127.0.0.0/8or::1— so thehttp://localhost:4000quickstart default keeps working; - refused:
http://on any other host, with aTypeErrornaming 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 testReleasing
Publishing is fully automated by typescript-sdk-release.yml. To ship a new version:
- Bump
versioninpackage.jsonand update the CHANGELOG. - Merge to
main. - 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.
