@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.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/client.d.ts +747 -0
- package/dist/client.js +902 -0
- package/dist/enqueue.d.ts +119 -0
- package/dist/enqueue.js +110 -0
- package/dist/handler.d.ts +179 -0
- package/dist/handler.js +45 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/query.d.ts +372 -0
- package/dist/query.js +653 -0
- package/dist/resources.d.ts +281 -0
- package/dist/resources.js +319 -0
- package/dist/unique-key.d.ts +58 -0
- package/dist/unique-key.js +122 -0
- package/dist/worker.d.ts +307 -0
- package/dist/worker.js +396 -0
- package/package.json +34 -0
|
@@ -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
|
+
}
|
package/dist/worker.d.ts
ADDED
|
@@ -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
|
+
}
|