@zizq-labs/zizq 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ // Copyright (c) 2026 Chris Corbyn <chris@zizq.io>
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+ /**
4
+ * Helpers for building unique keys from job payloads.
5
+ *
6
+ * The returned functions are suitable for assigning to `zizqOptions.uniqueKey`
7
+ * on a job function.
8
+ *
9
+ * @module
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ /**
13
+ * Build a function that computes a unique key from a subset of the payload.
14
+ *
15
+ * At enqueue time, the named fields are picked from the payload,
16
+ * round-tripped through `JSON` to normalise any exotic values, hashed
17
+ * with SHA-256, and prefixed with the job type.
18
+ *
19
+ * When no fields are passed, the entire payload is hashed.
20
+ *
21
+ * The returned function is assigned to `zizqOptions.uniqueKey` on a job
22
+ * function. The resolver is called with `(fn, payload)` at enqueue time.
23
+ *
24
+ * For cross-type deduplication (e.g. a push notification and an email
25
+ * that represent the same logical event), write your own plain function
26
+ * with whatever key format you want; it will pass through unchanged.
27
+ *
28
+ * @example Unique by specific payload fields
29
+ * ```ts
30
+ * import { uniqueKey } from "@zizq-labs/zizq";
31
+ *
32
+ * async function sendEmail(payload) { ... }
33
+ * sendEmail.zizqOptions = {
34
+ * queue: "emails",
35
+ * uniqueKey: uniqueKey("userId", "action"),
36
+ * uniqueWhile: "queued",
37
+ * };
38
+ * ```
39
+ *
40
+ * @example Unique by the entire payload
41
+ * ```ts
42
+ * sendEmail.zizqOptions = {
43
+ * uniqueKey: uniqueKey(),
44
+ * };
45
+ * ```
46
+ */
47
+ export function uniqueKey(...fields) {
48
+ return (fn, payload) => {
49
+ const jobType = fn.zizqOptions?.type ?? fn.name;
50
+ if (!jobType) {
51
+ throw new Error("uniqueKey: job function must have a name or zizqOptions.type");
52
+ }
53
+ let input;
54
+ if (fields.length === 0) {
55
+ input = payload;
56
+ }
57
+ else {
58
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
59
+ throw new Error(`uniqueKey: cannot pick fields from a non-object payload (got ${payload === null ? "null" : Array.isArray(payload) ? "array" : typeof payload}). Use uniqueKey() with no fields to hash the whole payload, or write a custom resolver.`);
60
+ }
61
+ input = pick(payload, fields);
62
+ }
63
+ // Round-trip through JSON to normalise any exotic values (Dates,
64
+ // NaN, undefined, BigInt, functions, etc.) and detect circular
65
+ // references. The result is plain JSON-compatible data.
66
+ const normalised = JSON.parse(JSON.stringify(input));
67
+ const hash = createHash("sha256");
68
+ hashInto(hash, normalised);
69
+ return `${jobType}:${hash.digest("hex")}`;
70
+ };
71
+ }
72
+ // --- Internal helpers ---
73
+ /** Extract the given top-level fields from an object, preserving order. */
74
+ function pick(obj, fields) {
75
+ const result = {};
76
+ for (const field of fields) {
77
+ if (field in obj)
78
+ result[field] = obj[field];
79
+ }
80
+ return result;
81
+ }
82
+ /**
83
+ * Stream a JSON-compatible value into a crypto hash as canonical JSON:
84
+ * object keys sorted, arrays in order, primitives emitted via `JSON.stringify`.
85
+ *
86
+ * The resulting byte stream is unambiguous because strings are quoted,
87
+ * `null`/`true`/`false` are fixed tokens, and commas separate items within
88
+ * containers (so `[1,2]` and `[12]` hash differently).
89
+ *
90
+ * The input must already be normalised JSON data, as from
91
+ * `JSON.parse(JSON.stringify(x))`.
92
+ */
93
+ export function hashInto(hash, value) {
94
+ // Bare number, string, boolean or null. Use JSON repr.
95
+ if (value === null || typeof value !== "object") {
96
+ hash.update(JSON.stringify(value));
97
+ return;
98
+ }
99
+ // Arrays hash in the original order, with "[" and "]" markers.
100
+ // We don't worry about the trailing comma; we just need a stable digest.
101
+ if (Array.isArray(value)) {
102
+ hash.update("[");
103
+ for (const item of value) {
104
+ hashInto(hash, item);
105
+ hash.update(",");
106
+ }
107
+ hash.update("]");
108
+ return;
109
+ }
110
+ // Objects hash in key-sorted order, with "{" and "}" markers.
111
+ // We don't worry about the trailing comma; we just need a stable digest.
112
+ hash.update("{");
113
+ const obj = value;
114
+ const keys = Object.keys(obj).sort();
115
+ for (const key of keys) {
116
+ hash.update(JSON.stringify(key));
117
+ hash.update(":");
118
+ hashInto(hash, obj[key]);
119
+ hash.update(",");
120
+ }
121
+ hash.update("}");
122
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * In-process worker that takes jobs from the Zizq server and dispatches
3
+ * them to a single handler function.
4
+ *
5
+ * The handler can either route jobs manually (e.g. via a `switch` on
6
+ * `job.type`) or use `buildHandler([...])` to build a dispatcher
7
+ * from an array of named `JobFunction`s.
8
+ *
9
+ * @example Function-based dispatch
10
+ * ```ts
11
+ * import { Client, Worker, buildHandler } from "@zizq-labs/zizq";
12
+ *
13
+ * async function sendEmail(payload) { ... }
14
+ * sendEmail.zizqOptions = { queue: "emails" };
15
+ *
16
+ * async function generateReport(payload) { ... }
17
+ * generateReport.zizqOptions = { queue: "reports" };
18
+ *
19
+ * const client = new Client({ url: "http://localhost:7890" });
20
+ * const worker = new Worker({
21
+ * client,
22
+ * concurrency: 10,
23
+ * handler: buildHandler([sendEmail, generateReport]),
24
+ * });
25
+ *
26
+ * // Blocks until stopped.
27
+ * await worker.run();
28
+ * ```
29
+ *
30
+ * @example Manual dispatch (low-level / cross-language)
31
+ * ```ts
32
+ * const worker = new Worker({
33
+ * client,
34
+ * queues: ["payments"],
35
+ * concurrency: 5,
36
+ * handler: async (job) => {
37
+ * switch (job.type) {
38
+ * case "charge_card": return chargeCard(job.payload);
39
+ * case "send_receipt": return sendReceipt(job.payload);
40
+ * default: throw new Error(`Unknown job type: ${job.type}`);
41
+ * }
42
+ * },
43
+ * });
44
+ *
45
+ * await worker.run();
46
+ * ```
47
+ *
48
+ * @example Graceful shutdown
49
+ * ```ts
50
+ * process.on("SIGTERM", () => worker.stop());
51
+ * ```
52
+ *
53
+ * @module
54
+ */
55
+ import { Client } from "./client.ts";
56
+ import type { JobHandler } from "./handler.ts";
57
+ /**
58
+ * Options for constructing a {@link Worker}.
59
+ */
60
+ export interface WorkerOptions {
61
+ /** Zizq client instance to use for all server communication. */
62
+ client: Client;
63
+ /**
64
+ * Handler function called for every job received.
65
+ *
66
+ * For function-based dispatch (looking up handlers by job type), use
67
+ * `buildHandler([...])` to build a dispatcher from an array
68
+ * of `JobFunction`s.
69
+ *
70
+ * @example Manual dispatch
71
+ * ```ts
72
+ * new Worker({
73
+ * client,
74
+ * handler: async (job) => {
75
+ * switch (job.type) {
76
+ * case "charge_card": return chargeCard(job.payload);
77
+ * }
78
+ * },
79
+ * });
80
+ * ```
81
+ *
82
+ * @example Function-based dispatch
83
+ * ```ts
84
+ * import { buildHandler } from "@zizq-labs/zizq";
85
+ *
86
+ * new Worker({
87
+ * client,
88
+ * handler: buildHandler([sendEmail, generateReport]),
89
+ * });
90
+ * ```
91
+ */
92
+ handler: JobHandler;
93
+ /**
94
+ * Maximum number of jobs to process concurrently.
95
+ *
96
+ * Default: 1 (sequential processing).
97
+ */
98
+ concurrency?: number;
99
+ /**
100
+ * Maximum number of unacknowledged jobs the server will send ahead.
101
+ *
102
+ * A higher prefetch than concurrency keeps jobs buffered in the stream
103
+ * so there's always work ready when a processing slot opens, avoiding
104
+ * a round-trip delay between finishing a job and starting the next.
105
+ *
106
+ * Default: same as `concurrency`.
107
+ */
108
+ prefetch?: number;
109
+ /**
110
+ * Queues to take jobs from.
111
+ *
112
+ * When omitted, the worker takes from all queues.
113
+ */
114
+ queues?: string[];
115
+ /**
116
+ * Logger for worker diagnostics (retry warnings, unrecoverable errors).
117
+ *
118
+ * Must implement at least an `error` method. Any logger that satisfies
119
+ * this (console, pino, winston, etc.) works out of the box.
120
+ *
121
+ * Default: `console`.
122
+ */
123
+ logger?: Logger;
124
+ /**
125
+ * Retry configuration for transient HTTP failures (connection drops,
126
+ * server errors). Applies to both the take stream (reconnection) and
127
+ * ack/nack requests.
128
+ *
129
+ * This is unrelated to Zizq's job-level retry/backoff — it controls
130
+ * how the worker retries its own communication with the server.
131
+ *
132
+ * Client errors (4xx) are never retried.
133
+ */
134
+ requestRetry?: RequestRetryOptions;
135
+ }
136
+ /**
137
+ * Configuration for ack/nack retry backoff.
138
+ *
139
+ * The delay between retries follows: `min(initialDelay * multiplier^(attempt-1), maxDelay)`.
140
+ */
141
+ export interface RequestRetryOptions {
142
+ /**
143
+ * Initial delay in milliseconds before the first retry.
144
+ *
145
+ * Default: 500.
146
+ */
147
+ initialDelay?: number;
148
+ /**
149
+ * Maximum delay in milliseconds between retries.
150
+ *
151
+ * Default: 30000 (30 seconds).
152
+ */
153
+ maxDelay?: number;
154
+ /**
155
+ * Multiplier applied to the delay after each failed attempt.
156
+ *
157
+ * Default: 2.
158
+ */
159
+ multiplier?: number;
160
+ }
161
+ /** Minimal logger interface used by the worker. */
162
+ export interface Logger {
163
+ info(...args: unknown[]): void;
164
+ error(...args: unknown[]): void;
165
+ }
166
+ /**
167
+ * In-process worker that takes jobs from the Zizq server and dispatches them
168
+ * to registered handlers.
169
+ *
170
+ * Manages concurrency via a prefetch-based flow control model: the server
171
+ * sends up to `prefetch` unacknowledged jobs, and the worker processes
172
+ * up to `concurrency` of them concurrently using `Promise.race` to stay
173
+ * within the limit.
174
+ *
175
+ * Jobs that complete successfully are acknowledged automatically. Jobs that
176
+ * throw are reported as failures (with error message, type, and stack trace),
177
+ * and the server handles retry scheduling based on the backoff policy.
178
+ */
179
+ export declare class Worker {
180
+ private client;
181
+ private concurrency;
182
+ private prefetch;
183
+ private queues;
184
+ private logger;
185
+ private retryInitialDelay;
186
+ private retryMaxDelay;
187
+ private retryMultiplier;
188
+ private handler;
189
+ private abortController;
190
+ private streamController;
191
+ private inFlight;
192
+ private shutdownPromise;
193
+ private killing;
194
+ private killDeferred;
195
+ private pendingAcks;
196
+ private ackFlushInFlight;
197
+ private ackFlushPromise;
198
+ constructor(options: WorkerOptions);
199
+ /**
200
+ * Start processing jobs. Blocks until {@link stop} is called.
201
+ *
202
+ * Opens a streaming connection to the server's take endpoint and
203
+ * dispatches incoming jobs to the registered handlers concurrently.
204
+ *
205
+ * Automatically reconnects with exponential backoff if the connection
206
+ * drops or the server is unreachable. The backoff is reset after a
207
+ * successful connection. Uses the `requestRetry` configuration.
208
+ *
209
+ * On shutdown, waits for all in-flight jobs to complete and flushes
210
+ * pending acks before returning.
211
+ *
212
+ * @example
213
+ * ```ts
214
+ * const worker = new Worker({ client, jobs: [sendEmail] });
215
+ *
216
+ * // In another context (e.g. signal handler):
217
+ * process.on("SIGTERM", () => worker.stop());
218
+ *
219
+ * // Blocks here until stopped.
220
+ * await worker.run();
221
+ * ```
222
+ */
223
+ run(): Promise<void>;
224
+ /**
225
+ * Reset all mutable runtime state so {@link run} can be called
226
+ * multiple times on the same Worker instance. Called from the top of
227
+ * {@link run}. Config (client, handler, concurrency, etc.) is
228
+ * preserved; only per-run state is wiped.
229
+ */
230
+ private resetRuntimeState;
231
+ /**
232
+ * Async sequence triggered by the shutdown listener when `stop()` or
233
+ * `kill()` is called. On a graceful stop, drains in-flight jobs and
234
+ * flushes pending acks before aborting the take stream. On kill, the
235
+ * drain loop bails out early via the `killing` flag, any pending
236
+ * acks are dropped, and the stream is (already) aborted by `kill()`
237
+ * itself.
238
+ *
239
+ * The drain loop races `Promise.allSettled(inFlight)` against
240
+ * `killDeferred.promise` so that a `kill()` call mid-drain wakes the
241
+ * loop immediately, rather than having to wait for an in-flight
242
+ * handler to finish before noticing.
243
+ */
244
+ private startShutdown;
245
+ /**
246
+ * Request a graceful shutdown.
247
+ *
248
+ * Stops dispatching new jobs, waits for all in-flight job handlers to
249
+ * finish, flushes any pending acks, and then aborts the take stream.
250
+ * {@link run} returns once all of that has drained. `stop` is patient
251
+ * — there is no internal deadline. If you need to bound how long
252
+ * shutdown can take, use {@link kill} as an escalation.
253
+ *
254
+ * Callable any number of times; subsequent calls are no-ops.
255
+ */
256
+ stop(): void;
257
+ /**
258
+ * Force an immediate shutdown.
259
+ *
260
+ * Aborts the take stream right away and skips the drain + flush
261
+ * steps. Any in-flight job handlers that are already running will
262
+ * continue to completion. JavaScript can't cancel promises, but
263
+ * their acks will not be flushed, so the server will re-queue the
264
+ * corresponding jobs once the worker disconnects.
265
+ *
266
+ * Safe to call after `stop()` has already been called; this is the
267
+ * deadline-escalation path. A `stop()` drain that's been running too
268
+ * long will wake up immediately once `kill()` is called, and
269
+ * {@link run} returns shortly after.
270
+ */
271
+ kill(): void;
272
+ /**
273
+ * Process a single job: dispatch to the handler, then ack or fail.
274
+ *
275
+ * Success acks are batched — the job ID is buffered and a flush is
276
+ * scheduled via `setImmediate`. This means the worker moves on to
277
+ * the next job immediately without waiting for the ack round-trip.
278
+ *
279
+ * Failures are reported individually (they carry per-job error details)
280
+ * and are retried on transient errors.
281
+ */
282
+ private processJob;
283
+ /**
284
+ * Buffer a success ack and schedule a flush.
285
+ *
286
+ * If a flush is already in flight, the ack simply accumulates in the
287
+ * buffer; it will be picked up when the current flush completes and
288
+ * schedules the next one.
289
+ */
290
+ private scheduleAck;
291
+ /**
292
+ * Send all buffered acks in a single bulk request.
293
+ *
294
+ * After the request completes (or fails with retry), if more acks have
295
+ * accumulated during the flush, schedule another flush immediately.
296
+ */
297
+ private flushAcks;
298
+ /**
299
+ * Retry an async operation with exponential backoff for transient errors.
300
+ *
301
+ * Client errors (4xx) are not retried — they indicate a permanent problem
302
+ * with the request. Connection errors and server errors (5xx) are retried
303
+ * indefinitely with exponential backoff. Retry timing is configured via
304
+ * `requestRetry` in `WorkerOptions`.
305
+ */
306
+ private withRetry;
307
+ }