@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
package/dist/worker.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Copyright (c) 2026 Chris Corbyn <chris@zizq.io>
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
/**
|
|
4
|
+
* In-process worker that takes jobs from the Zizq server and dispatches
|
|
5
|
+
* them to a single handler function.
|
|
6
|
+
*
|
|
7
|
+
* The handler can either route jobs manually (e.g. via a `switch` on
|
|
8
|
+
* `job.type`) or use `buildHandler([...])` to build a dispatcher
|
|
9
|
+
* from an array of named `JobFunction`s.
|
|
10
|
+
*
|
|
11
|
+
* @example Function-based dispatch
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { Client, Worker, buildHandler } from "@zizq-labs/zizq";
|
|
14
|
+
*
|
|
15
|
+
* async function sendEmail(payload) { ... }
|
|
16
|
+
* sendEmail.zizqOptions = { queue: "emails" };
|
|
17
|
+
*
|
|
18
|
+
* async function generateReport(payload) { ... }
|
|
19
|
+
* generateReport.zizqOptions = { queue: "reports" };
|
|
20
|
+
*
|
|
21
|
+
* const client = new Client({ url: "http://localhost:7890" });
|
|
22
|
+
* const worker = new Worker({
|
|
23
|
+
* client,
|
|
24
|
+
* concurrency: 10,
|
|
25
|
+
* handler: buildHandler([sendEmail, generateReport]),
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* // Blocks until stopped.
|
|
29
|
+
* await worker.run();
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @example Manual dispatch (low-level / cross-language)
|
|
33
|
+
* ```ts
|
|
34
|
+
* const worker = new Worker({
|
|
35
|
+
* client,
|
|
36
|
+
* queues: ["payments"],
|
|
37
|
+
* concurrency: 5,
|
|
38
|
+
* handler: async (job) => {
|
|
39
|
+
* switch (job.type) {
|
|
40
|
+
* case "charge_card": return chargeCard(job.payload);
|
|
41
|
+
* case "send_receipt": return sendReceipt(job.payload);
|
|
42
|
+
* default: throw new Error(`Unknown job type: ${job.type}`);
|
|
43
|
+
* }
|
|
44
|
+
* },
|
|
45
|
+
* });
|
|
46
|
+
*
|
|
47
|
+
* await worker.run();
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @example Graceful shutdown
|
|
51
|
+
* ```ts
|
|
52
|
+
* process.on("SIGTERM", () => worker.stop());
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* @module
|
|
56
|
+
*/
|
|
57
|
+
import { ClientError, } from "./client.js";
|
|
58
|
+
/**
|
|
59
|
+
* In-process worker that takes jobs from the Zizq server and dispatches them
|
|
60
|
+
* to registered handlers.
|
|
61
|
+
*
|
|
62
|
+
* Manages concurrency via a prefetch-based flow control model: the server
|
|
63
|
+
* sends up to `prefetch` unacknowledged jobs, and the worker processes
|
|
64
|
+
* up to `concurrency` of them concurrently using `Promise.race` to stay
|
|
65
|
+
* within the limit.
|
|
66
|
+
*
|
|
67
|
+
* Jobs that complete successfully are acknowledged automatically. Jobs that
|
|
68
|
+
* throw are reported as failures (with error message, type, and stack trace),
|
|
69
|
+
* and the server handles retry scheduling based on the backoff policy.
|
|
70
|
+
*/
|
|
71
|
+
export class Worker {
|
|
72
|
+
client;
|
|
73
|
+
concurrency;
|
|
74
|
+
prefetch;
|
|
75
|
+
queues;
|
|
76
|
+
logger;
|
|
77
|
+
retryInitialDelay;
|
|
78
|
+
retryMaxDelay;
|
|
79
|
+
retryMultiplier;
|
|
80
|
+
handler;
|
|
81
|
+
// Two abort controllers for a coordinated two-phase shutdown:
|
|
82
|
+
// - `abortController` is the user-facing signal, fired by `stop()`.
|
|
83
|
+
// When it fires, the worker begins draining in-flight jobs and
|
|
84
|
+
// flushing acks while the take stream is still alive.
|
|
85
|
+
// - `streamController` is the internal signal passed to the take
|
|
86
|
+
// request. It is only aborted *after* the drain + flush completes,
|
|
87
|
+
// so the server keeps jobs as in-flight during the drain and
|
|
88
|
+
// accepts the acks before the connection closes.
|
|
89
|
+
//
|
|
90
|
+
// Reassigned fresh on every `run()` via `resetRuntimeState()`.
|
|
91
|
+
abortController = new AbortController();
|
|
92
|
+
streamController = new AbortController();
|
|
93
|
+
// Set of promises for currently in-flight `processJob` tasks. Exposed
|
|
94
|
+
// as an instance field so the shutdown listener can drain them.
|
|
95
|
+
inFlight = new Set();
|
|
96
|
+
// Promise representing the drain + flush + stream-abort sequence
|
|
97
|
+
// initiated by the shutdown listener. `run()` awaits this before
|
|
98
|
+
// returning so callers can observe any errors from the shutdown path.
|
|
99
|
+
shutdownPromise = Promise.resolve();
|
|
100
|
+
// Set to `true` when `kill()` is called. The drain loop in
|
|
101
|
+
// `startShutdown` checks this to bail out early, and `kill()` itself
|
|
102
|
+
// aborts `streamController` directly so the take loop exits without
|
|
103
|
+
// waiting for the drain.
|
|
104
|
+
//
|
|
105
|
+
// `killDeferred` is a promise that resolves when `kill()` is called.
|
|
106
|
+
// The drain loop races against it so it wakes up immediately on kill,
|
|
107
|
+
// otherwise a slow in-flight job could keep the drain loop blocked
|
|
108
|
+
// on `Promise.allSettled` for an unbounded amount of time.
|
|
109
|
+
killing = false;
|
|
110
|
+
killDeferred = Promise.withResolvers();
|
|
111
|
+
// Bulk ack batching buffer.
|
|
112
|
+
// Success acks are buffered and flushed in a single bulk request.
|
|
113
|
+
// The bulk ack is scheduled via queueMicrotask and only one bulk ack runs at
|
|
114
|
+
// a time. While a bulk ack request is in flight, new acks accumulate and
|
|
115
|
+
// are sent in the next batch.
|
|
116
|
+
pendingAcks = [];
|
|
117
|
+
ackFlushInFlight = false;
|
|
118
|
+
ackFlushPromise = Promise.resolve();
|
|
119
|
+
constructor(options) {
|
|
120
|
+
this.client = options.client;
|
|
121
|
+
this.handler = options.handler;
|
|
122
|
+
this.concurrency = options.concurrency ?? 1;
|
|
123
|
+
this.prefetch = options.prefetch ?? this.concurrency;
|
|
124
|
+
this.logger = options.logger ?? console;
|
|
125
|
+
this.retryInitialDelay = options.requestRetry?.initialDelay ?? 500;
|
|
126
|
+
this.retryMaxDelay = options.requestRetry?.maxDelay ?? 30_000;
|
|
127
|
+
this.retryMultiplier = options.requestRetry?.multiplier ?? 2;
|
|
128
|
+
this.queues = options.queues ?? [];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Start processing jobs. Blocks until {@link stop} is called.
|
|
132
|
+
*
|
|
133
|
+
* Opens a streaming connection to the server's take endpoint and
|
|
134
|
+
* dispatches incoming jobs to the registered handlers concurrently.
|
|
135
|
+
*
|
|
136
|
+
* Automatically reconnects with exponential backoff if the connection
|
|
137
|
+
* drops or the server is unreachable. The backoff is reset after a
|
|
138
|
+
* successful connection. Uses the `requestRetry` configuration.
|
|
139
|
+
*
|
|
140
|
+
* On shutdown, waits for all in-flight jobs to complete and flushes
|
|
141
|
+
* pending acks before returning.
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```ts
|
|
145
|
+
* const worker = new Worker({ client, jobs: [sendEmail] });
|
|
146
|
+
*
|
|
147
|
+
* // In another context (e.g. signal handler):
|
|
148
|
+
* process.on("SIGTERM", () => worker.stop());
|
|
149
|
+
*
|
|
150
|
+
* // Blocks here until stopped.
|
|
151
|
+
* await worker.run();
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
async run() {
|
|
155
|
+
this.resetRuntimeState();
|
|
156
|
+
// When the user calls stop(), begin an async shutdown sequence:
|
|
157
|
+
// drain in-flight jobs, flush acks, then abort the stream. The
|
|
158
|
+
// stream stays alive during the drain so the server keeps jobs
|
|
159
|
+
// marked in-flight and accepts the acks.
|
|
160
|
+
this.abortController.signal.addEventListener("abort", () => this.startShutdown(), { once: true });
|
|
161
|
+
let attempt = 0;
|
|
162
|
+
while (!this.abortController.signal.aborted) {
|
|
163
|
+
this.streamController = new AbortController();
|
|
164
|
+
try {
|
|
165
|
+
const takeOpts = {
|
|
166
|
+
prefetch: this.prefetch,
|
|
167
|
+
queues: this.queues.length > 0 ? this.queues : undefined,
|
|
168
|
+
signal: this.streamController.signal,
|
|
169
|
+
};
|
|
170
|
+
const stream = await this.client.take(takeOpts);
|
|
171
|
+
if (attempt > 0) {
|
|
172
|
+
this.logger.info(`[zizq] reconnected to ${this.client.url}`);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
this.logger.info(`[zizq] connected to ${this.client.url}`);
|
|
176
|
+
}
|
|
177
|
+
attempt = 0;
|
|
178
|
+
for await (const job of stream) {
|
|
179
|
+
// Once stop() has been called, don't dispatch any more jobs.
|
|
180
|
+
// The shutdown listener is draining in-flight and will abort
|
|
181
|
+
// the stream when done. Keep reading so heartbeats flow and
|
|
182
|
+
// the connection stays alive during the drain.
|
|
183
|
+
if (this.abortController.signal.aborted)
|
|
184
|
+
continue;
|
|
185
|
+
const task = this.processJob(job).finally(() => {
|
|
186
|
+
this.inFlight.delete(task);
|
|
187
|
+
});
|
|
188
|
+
this.inFlight.add(task);
|
|
189
|
+
// If we've hit concurrency limit, wait for one to finish.
|
|
190
|
+
if (this.inFlight.size >= this.concurrency) {
|
|
191
|
+
await Promise.race(this.inFlight);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
if (this.abortController.signal.aborted) {
|
|
197
|
+
// Expected — stream aborted by the shutdown listener.
|
|
198
|
+
}
|
|
199
|
+
else if (err instanceof ClientError) {
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
attempt++;
|
|
204
|
+
const delay = Math.min(this.retryInitialDelay * Math.pow(this.retryMultiplier, attempt - 1), this.retryMaxDelay);
|
|
205
|
+
this.logger.error(`[zizq] disconnected from ${this.client.url} (attempt ${attempt}, reconnecting in ${delay}ms):`, err instanceof Error ? err.message : err);
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Wait for the shutdown sequence initiated by the listener to
|
|
212
|
+
// complete (drain + flush + stream abort).
|
|
213
|
+
await this.shutdownPromise;
|
|
214
|
+
this.logger.info("[zizq] worker stopped");
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Reset all mutable runtime state so {@link run} can be called
|
|
218
|
+
* multiple times on the same Worker instance. Called from the top of
|
|
219
|
+
* {@link run}. Config (client, handler, concurrency, etc.) is
|
|
220
|
+
* preserved; only per-run state is wiped.
|
|
221
|
+
*/
|
|
222
|
+
resetRuntimeState() {
|
|
223
|
+
this.abortController = new AbortController();
|
|
224
|
+
this.streamController = new AbortController();
|
|
225
|
+
this.inFlight = new Set();
|
|
226
|
+
this.shutdownPromise = Promise.resolve();
|
|
227
|
+
this.killing = false;
|
|
228
|
+
this.killDeferred = Promise.withResolvers();
|
|
229
|
+
this.pendingAcks = [];
|
|
230
|
+
this.ackFlushInFlight = false;
|
|
231
|
+
this.ackFlushPromise = Promise.resolve();
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Async sequence triggered by the shutdown listener when `stop()` or
|
|
235
|
+
* `kill()` is called. On a graceful stop, drains in-flight jobs and
|
|
236
|
+
* flushes pending acks before aborting the take stream. On kill, the
|
|
237
|
+
* drain loop bails out early via the `killing` flag, any pending
|
|
238
|
+
* acks are dropped, and the stream is (already) aborted by `kill()`
|
|
239
|
+
* itself.
|
|
240
|
+
*
|
|
241
|
+
* The drain loop races `Promise.allSettled(inFlight)` against
|
|
242
|
+
* `killDeferred.promise` so that a `kill()` call mid-drain wakes the
|
|
243
|
+
* loop immediately, rather than having to wait for an in-flight
|
|
244
|
+
* handler to finish before noticing.
|
|
245
|
+
*/
|
|
246
|
+
startShutdown() {
|
|
247
|
+
this.shutdownPromise = (async () => {
|
|
248
|
+
try {
|
|
249
|
+
// Drain in-flight jobs, bailing out early if kill() is called.
|
|
250
|
+
while (this.inFlight.size > 0 && !this.killing) {
|
|
251
|
+
await Promise.race([
|
|
252
|
+
Promise.allSettled([...this.inFlight]),
|
|
253
|
+
this.killDeferred.promise,
|
|
254
|
+
]);
|
|
255
|
+
}
|
|
256
|
+
if (!this.killing) {
|
|
257
|
+
// Graceful path: flush any pending acks while the stream is
|
|
258
|
+
// still alive.
|
|
259
|
+
await this.ackFlushPromise;
|
|
260
|
+
await this.flushAcks();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
// Abort the take stream. Idempotent — if kill() already aborted
|
|
265
|
+
// it, this call is a no-op.
|
|
266
|
+
this.streamController.abort();
|
|
267
|
+
}
|
|
268
|
+
})();
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Request a graceful shutdown.
|
|
272
|
+
*
|
|
273
|
+
* Stops dispatching new jobs, waits for all in-flight job handlers to
|
|
274
|
+
* finish, flushes any pending acks, and then aborts the take stream.
|
|
275
|
+
* {@link run} returns once all of that has drained. `stop` is patient
|
|
276
|
+
* — there is no internal deadline. If you need to bound how long
|
|
277
|
+
* shutdown can take, use {@link kill} as an escalation.
|
|
278
|
+
*
|
|
279
|
+
* Callable any number of times; subsequent calls are no-ops.
|
|
280
|
+
*/
|
|
281
|
+
stop() {
|
|
282
|
+
this.logger.info("[zizq] stopping worker...");
|
|
283
|
+
if (!this.abortController.signal.aborted) {
|
|
284
|
+
this.abortController.abort();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Force an immediate shutdown.
|
|
289
|
+
*
|
|
290
|
+
* Aborts the take stream right away and skips the drain + flush
|
|
291
|
+
* steps. Any in-flight job handlers that are already running will
|
|
292
|
+
* continue to completion. JavaScript can't cancel promises, but
|
|
293
|
+
* their acks will not be flushed, so the server will re-queue the
|
|
294
|
+
* corresponding jobs once the worker disconnects.
|
|
295
|
+
*
|
|
296
|
+
* Safe to call after `stop()` has already been called; this is the
|
|
297
|
+
* deadline-escalation path. A `stop()` drain that's been running too
|
|
298
|
+
* long will wake up immediately once `kill()` is called, and
|
|
299
|
+
* {@link run} returns shortly after.
|
|
300
|
+
*/
|
|
301
|
+
kill() {
|
|
302
|
+
this.logger.info("[zizq] killing worker...");
|
|
303
|
+
this.killing = true;
|
|
304
|
+
// Wake the drain loop in startShutdown() if it's blocked on
|
|
305
|
+
// Promise.allSettled.
|
|
306
|
+
this.killDeferred.resolve();
|
|
307
|
+
// Close the stream immediately so the take loop exits.
|
|
308
|
+
this.streamController.abort();
|
|
309
|
+
// Trigger startShutdown if stop() hasn't been called yet.
|
|
310
|
+
if (!this.abortController.signal.aborted) {
|
|
311
|
+
this.abortController.abort();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Process a single job: dispatch to the handler, then ack or fail.
|
|
316
|
+
*
|
|
317
|
+
* Success acks are batched — the job ID is buffered and a flush is
|
|
318
|
+
* scheduled via `setImmediate`. This means the worker moves on to
|
|
319
|
+
* the next job immediately without waiting for the ack round-trip.
|
|
320
|
+
*
|
|
321
|
+
* Failures are reported individually (they carry per-job error details)
|
|
322
|
+
* and are retried on transient errors.
|
|
323
|
+
*/
|
|
324
|
+
async processJob(job) {
|
|
325
|
+
try {
|
|
326
|
+
await this.handler(job);
|
|
327
|
+
this.scheduleAck(job.id);
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
const failure = {
|
|
331
|
+
message: err instanceof Error ? err.message : String(err),
|
|
332
|
+
errorType: err instanceof Error ? err.constructor.name : undefined,
|
|
333
|
+
backtrace: err instanceof Error ? err.stack : undefined,
|
|
334
|
+
};
|
|
335
|
+
await this.withRetry(() => this.client.reportFailure(job.id, failure));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Buffer a success ack and schedule a flush.
|
|
340
|
+
*
|
|
341
|
+
* If a flush is already in flight, the ack simply accumulates in the
|
|
342
|
+
* buffer; it will be picked up when the current flush completes and
|
|
343
|
+
* schedules the next one.
|
|
344
|
+
*/
|
|
345
|
+
scheduleAck(id) {
|
|
346
|
+
this.pendingAcks.push(id);
|
|
347
|
+
if (!this.ackFlushInFlight) {
|
|
348
|
+
this.ackFlushInFlight = true;
|
|
349
|
+
this.ackFlushPromise = new Promise((resolve) => {
|
|
350
|
+
setImmediate(() => this.flushAcks().then(resolve));
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Send all buffered acks in a single bulk request.
|
|
356
|
+
*
|
|
357
|
+
* After the request completes (or fails with retry), if more acks have
|
|
358
|
+
* accumulated during the flush, schedule another flush immediately.
|
|
359
|
+
*/
|
|
360
|
+
async flushAcks() {
|
|
361
|
+
while (this.pendingAcks.length > 0) {
|
|
362
|
+
const batch = this.pendingAcks;
|
|
363
|
+
this.pendingAcks = [];
|
|
364
|
+
await this.withRetry(() => this.client.reportSuccessBulk(batch));
|
|
365
|
+
}
|
|
366
|
+
this.ackFlushInFlight = false;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Retry an async operation with exponential backoff for transient errors.
|
|
370
|
+
*
|
|
371
|
+
* Client errors (4xx) are not retried — they indicate a permanent problem
|
|
372
|
+
* with the request. Connection errors and server errors (5xx) are retried
|
|
373
|
+
* indefinitely with exponential backoff. Retry timing is configured via
|
|
374
|
+
* `requestRetry` in `WorkerOptions`.
|
|
375
|
+
*/
|
|
376
|
+
async withRetry(fn) {
|
|
377
|
+
let attempt = 0;
|
|
378
|
+
while (true) {
|
|
379
|
+
try {
|
|
380
|
+
await fn();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
// Client errors (4xx) are not transient — don't retry.
|
|
385
|
+
if (err instanceof ClientError) {
|
|
386
|
+
this.logger.error("[zizq] ack/nack rejected:", err.message);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
attempt++;
|
|
390
|
+
const delay = Math.min(this.retryInitialDelay * Math.pow(this.retryMultiplier, attempt - 1), this.retryMaxDelay);
|
|
391
|
+
this.logger.error(`[zizq] ack/nack failed (attempt ${attempt}, retrying in ${delay}ms):`, err instanceof Error ? err.message : err);
|
|
392
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zizq-labs/zizq",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Node.js client for the Zizq job queue server",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22.19"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@msgpack/msgpack": "^3.1.3",
|
|
28
|
+
"undici": "^8.0.2"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^25.5.2",
|
|
32
|
+
"typescript": "^5.8.0"
|
|
33
|
+
}
|
|
34
|
+
}
|