@holo-js/queue-redis 0.1.3

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,143 @@
1
+ import { Job, ConnectionOptions } from 'bullmq';
2
+ import Redis from 'ioredis';
3
+
4
+ type QueueJsonValue = null | string | number | boolean | readonly QueueJsonValue[] | {
5
+ readonly [key: string]: QueueJsonValue;
6
+ };
7
+ interface QueueJobEnvelope<TPayload extends QueueJsonValue = QueueJsonValue> {
8
+ readonly id: string;
9
+ readonly name: string;
10
+ readonly connection: string;
11
+ readonly queue: string;
12
+ readonly payload: TPayload;
13
+ readonly attempts: number;
14
+ readonly maxAttempts: number;
15
+ readonly createdAt: number;
16
+ readonly availableAt?: number;
17
+ }
18
+ interface QueueDriverDispatchResult<TResult = unknown> {
19
+ readonly jobId: string;
20
+ readonly synchronous: boolean;
21
+ readonly result?: TResult;
22
+ }
23
+ interface QueueReservedJob<TPayload extends QueueJsonValue = QueueJsonValue> {
24
+ readonly reservationId: string;
25
+ readonly reservedAt: number;
26
+ readonly envelope: QueueJobEnvelope<TPayload>;
27
+ }
28
+ interface QueueReleaseOptions {
29
+ readonly delaySeconds?: number;
30
+ }
31
+ interface QueueDriverFactoryContext {
32
+ execute<TPayload extends QueueJsonValue = QueueJsonValue, TResult = unknown>(job: QueueJobEnvelope<TPayload>): Promise<TResult>;
33
+ }
34
+ interface QueueAsyncDriver {
35
+ readonly name: string;
36
+ readonly driver: 'redis';
37
+ readonly mode: 'async';
38
+ dispatch<TPayload extends QueueJsonValue = QueueJsonValue, TResult = unknown>(job: QueueJobEnvelope<TPayload>): Promise<QueueDriverDispatchResult<TResult>>;
39
+ reserve<TPayload extends QueueJsonValue = QueueJsonValue>(input: {
40
+ readonly queueNames?: readonly string[];
41
+ readonly workerId?: string;
42
+ readonly timeout?: number;
43
+ }): Promise<QueueReservedJob<TPayload> | null>;
44
+ acknowledge(job: QueueReservedJob): Promise<void>;
45
+ release(job: QueueReservedJob, options?: QueueReleaseOptions): Promise<void>;
46
+ delete(job: QueueReservedJob): Promise<void>;
47
+ clear(input?: {
48
+ readonly queueNames?: readonly string[];
49
+ }): Promise<number>;
50
+ close(): Promise<void>;
51
+ }
52
+ interface NormalizedQueueRedisConnectionConfig {
53
+ readonly name: string;
54
+ readonly driver: 'redis';
55
+ readonly connection: string;
56
+ readonly queue: string;
57
+ readonly retryAfter: number;
58
+ readonly blockFor: number;
59
+ readonly redis: {
60
+ readonly url?: string;
61
+ readonly clusters?: readonly {
62
+ readonly url?: string;
63
+ readonly host: string;
64
+ readonly port: number;
65
+ }[];
66
+ readonly host: string;
67
+ readonly port: number;
68
+ readonly password?: string;
69
+ readonly username?: string;
70
+ readonly db: number;
71
+ };
72
+ }
73
+ interface QueueDriverFactory<TConfig extends NormalizedQueueRedisConnectionConfig = NormalizedQueueRedisConnectionConfig> {
74
+ readonly driver: TConfig['driver'];
75
+ create(connection: TConfig, context: QueueDriverFactoryContext): QueueAsyncDriver;
76
+ }
77
+ type RedisQueuedEnvelope = QueueJobEnvelope<QueueJsonValue>;
78
+ type BullJobInstance = Job<RedisQueuedEnvelope, unknown, string>;
79
+ type QueueBullConnection = ConnectionOptions | Redis | InstanceType<(typeof Redis)['Cluster']>;
80
+ type RedisClusterStartupNode = {
81
+ readonly host: string;
82
+ readonly port: number;
83
+ readonly tls?: Record<string, never>;
84
+ };
85
+ declare function normalizeRedisErrorMessage(error: unknown): string;
86
+ declare function isQueueEnvelope(value: unknown): value is QueueJobEnvelope<QueueJsonValue>;
87
+ declare function toRedisSocketPath(value: string): string;
88
+ declare function parseClusterNodeUrl(url: string, label: string): RedisClusterStartupNode;
89
+ declare function resolveClusterStartupNodes(connection: NormalizedQueueRedisConnectionConfig): readonly RedisClusterStartupNode[];
90
+ declare function resolveBullConnectionOptions(connection: NormalizedQueueRedisConnectionConfig): ConnectionOptions;
91
+ declare function resolveBullConnection(connection: NormalizedQueueRedisConnectionConfig): QueueBullConnection;
92
+ declare class RedisQueueDriverError extends Error {
93
+ constructor(connectionName: string, action: string, cause: unknown);
94
+ }
95
+ declare function wrapRedisError(connectionName: string, action: string, error: unknown): RedisQueueDriverError;
96
+ declare function resolveAttempts(job: BullJobInstance): number;
97
+ declare class RedisQueueDriver implements QueueAsyncDriver {
98
+ private readonly context;
99
+ readonly name: string;
100
+ readonly driver: "redis";
101
+ readonly mode: "async";
102
+ private readonly connection;
103
+ private readonly bullConnection;
104
+ private readonly managedRedisConnection?;
105
+ private readonly queues;
106
+ private readonly workers;
107
+ private readonly reservations;
108
+ private queueCursor;
109
+ constructor(connection: NormalizedQueueRedisConnectionConfig, context: QueueDriverFactoryContext);
110
+ private getQueue;
111
+ private getWorker;
112
+ private normalizeQueueNames;
113
+ private rotateQueueNames;
114
+ private createReservedJob;
115
+ private getReservation;
116
+ private settleReservation;
117
+ dispatch<TPayload extends QueueJsonValue = QueueJsonValue, TResult = unknown>(job: QueueJobEnvelope<TPayload>): Promise<QueueDriverDispatchResult<TResult>>;
118
+ reserve<TPayload extends QueueJsonValue = QueueJsonValue>(input: {
119
+ readonly queueNames: readonly string[];
120
+ readonly workerId: string;
121
+ }): Promise<QueueReservedJob<TPayload> | null>;
122
+ acknowledge(job: QueueReservedJob): Promise<void>;
123
+ release(job: QueueReservedJob, options?: QueueReleaseOptions): Promise<void>;
124
+ delete(job: QueueReservedJob): Promise<void>;
125
+ clear(input?: {
126
+ readonly queueNames?: readonly string[];
127
+ }): Promise<number>;
128
+ close(): Promise<void>;
129
+ }
130
+ declare const redisQueueDriverFactory: QueueDriverFactory<NormalizedQueueRedisConnectionConfig>;
131
+ declare const redisQueueDriverInternals: {
132
+ isQueueEnvelope: typeof isQueueEnvelope;
133
+ normalizeRedisErrorMessage: typeof normalizeRedisErrorMessage;
134
+ parseClusterNodeUrl: typeof parseClusterNodeUrl;
135
+ resolveAttempts: typeof resolveAttempts;
136
+ resolveBullConnection: typeof resolveBullConnection;
137
+ resolveBullConnectionOptions: typeof resolveBullConnectionOptions;
138
+ resolveClusterStartupNodes: typeof resolveClusterStartupNodes;
139
+ toRedisSocketPath: typeof toRedisSocketPath;
140
+ wrapRedisError: typeof wrapRedisError;
141
+ };
142
+
143
+ export { type NormalizedQueueRedisConnectionConfig, type QueueAsyncDriver, type QueueDriverDispatchResult, type QueueDriverFactory, type QueueDriverFactoryContext, type QueueJobEnvelope, type QueueJsonValue, type QueueReleaseOptions, type QueueReservedJob, RedisQueueDriver, RedisQueueDriverError, redisQueueDriverFactory, redisQueueDriverInternals };
package/dist/index.mjs ADDED
@@ -0,0 +1,382 @@
1
+ // src/index.ts
2
+ import { randomUUID } from "crypto";
3
+ import {
4
+ Queue as BullQueue,
5
+ Worker as BullWorker
6
+ } from "bullmq";
7
+ import Redis from "ioredis";
8
+ function normalizeRedisErrorMessage(error) {
9
+ if (error instanceof Error) {
10
+ return error.message;
11
+ }
12
+ return String(error);
13
+ }
14
+ function isRecord(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ function isQueueEnvelope(value) {
18
+ return isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && typeof value.connection === "string" && typeof value.queue === "string" && "payload" in value && typeof value.attempts === "number" && Number.isInteger(value.attempts) && value.attempts >= 0 && typeof value.maxAttempts === "number" && Number.isInteger(value.maxAttempts) && value.maxAttempts >= 1 && typeof value.createdAt === "number" && Number.isFinite(value.createdAt) && (typeof value.availableAt === "undefined" || typeof value.availableAt === "number" && Number.isFinite(value.availableAt));
19
+ }
20
+ function isRedisSocketConnectionTarget(value) {
21
+ return value.startsWith("unix://") || value.startsWith("/");
22
+ }
23
+ function toRedisSocketPath(value) {
24
+ return value.startsWith("unix://") ? value.slice("unix://".length) : value;
25
+ }
26
+ function parseClusterNodeUrl(url, label) {
27
+ try {
28
+ const parsed = new URL(url);
29
+ if (parsed.protocol !== "redis:" && parsed.protocol !== "rediss:") {
30
+ throw new Error(`unsupported protocol "${parsed.protocol}"`);
31
+ }
32
+ if (!parsed.hostname) {
33
+ throw new Error("missing hostname");
34
+ }
35
+ return {
36
+ host: parsed.hostname,
37
+ port: parsed.port ? Number.parseInt(parsed.port, 10) : 6379,
38
+ ...parsed.protocol === "rediss:" ? { tls: {} } : {}
39
+ };
40
+ } catch (error) {
41
+ throw new Error(`[Holo Queue] ${label} is invalid: ${error instanceof Error ? error.message : String(error)}`);
42
+ }
43
+ }
44
+ function resolveClusterStartupNodes(connection) {
45
+ return (connection.redis.clusters ?? []).map((node, index) => {
46
+ const label = `Redis queue connection "${connection.name}" cluster node ${index + 1}`;
47
+ if (typeof node.url === "string") {
48
+ return parseClusterNodeUrl(node.url, `${label} url`);
49
+ }
50
+ if (isRedisSocketConnectionTarget(node.host)) {
51
+ throw new Error(`[Holo Queue] ${label} cannot use a Unix socket path in Redis cluster mode.`);
52
+ }
53
+ return {
54
+ host: node.host,
55
+ port: node.port
56
+ };
57
+ });
58
+ }
59
+ function resolveBullConnectionOptions(connection) {
60
+ const redisHost = connection.redis.host;
61
+ return {
62
+ ...typeof connection.redis.url === "string" ? { url: connection.redis.url } : typeof redisHost === "string" && isRedisSocketConnectionTarget(redisHost) ? { path: toRedisSocketPath(redisHost) } : {
63
+ host: redisHost,
64
+ port: connection.redis.port
65
+ },
66
+ username: connection.redis.username,
67
+ password: connection.redis.password,
68
+ db: connection.redis.db,
69
+ maxRetriesPerRequest: null
70
+ };
71
+ }
72
+ function resolveBullConnection(connection) {
73
+ if (connection.redis.clusters && connection.redis.clusters.length > 0 && typeof connection.redis.url !== "string") {
74
+ if (connection.redis.db !== 0) {
75
+ throw new Error(
76
+ `[Holo Queue] Redis queue connection "${connection.name}" cannot select redis.db=${connection.redis.db} in cluster mode; Redis Cluster only supports database 0.`
77
+ );
78
+ }
79
+ const startupNodes = [...resolveClusterStartupNodes(connection)];
80
+ return new Redis.Cluster(startupNodes.map(({ host, port }) => ({ host, port })), {
81
+ redisOptions: {
82
+ username: connection.redis.username,
83
+ password: connection.redis.password,
84
+ lazyConnect: true,
85
+ maxRetriesPerRequest: null,
86
+ ...startupNodes.some((node) => typeof node.tls !== "undefined") ? { tls: {} } : {}
87
+ }
88
+ });
89
+ }
90
+ if (typeof connection.redis.url === "string") {
91
+ return new Redis(connection.redis.url, {
92
+ username: connection.redis.username,
93
+ password: connection.redis.password,
94
+ db: connection.redis.db,
95
+ lazyConnect: true,
96
+ maxRetriesPerRequest: null,
97
+ ...connection.redis.url.startsWith("rediss://") ? { tls: {} } : {}
98
+ });
99
+ }
100
+ return resolveBullConnectionOptions(connection);
101
+ }
102
+ var RedisQueueDriverError = class extends Error {
103
+ constructor(connectionName, action, cause) {
104
+ super(
105
+ `[Holo Queue] Redis queue connection "${connectionName}" failed to ${action}: ${normalizeRedisErrorMessage(cause)}`,
106
+ { cause }
107
+ );
108
+ this.name = "RedisQueueDriverError";
109
+ }
110
+ };
111
+ function wrapRedisError(connectionName, action, error) {
112
+ if (error instanceof RedisQueueDriverError) {
113
+ return error;
114
+ }
115
+ return new RedisQueueDriverError(connectionName, action, error);
116
+ }
117
+ function resolveAttempts(job) {
118
+ const attemptsStarted = typeof job.attemptsStarted === "number" && Number.isInteger(job.attemptsStarted) ? job.attemptsStarted : 0;
119
+ const attemptsMade = typeof job.attemptsMade === "number" && Number.isInteger(job.attemptsMade) ? job.attemptsMade : 0;
120
+ return Math.max(
121
+ attemptsStarted > 0 ? attemptsStarted - 1 : 0,
122
+ attemptsMade,
123
+ 0
124
+ );
125
+ }
126
+ var RedisQueueDriver = class {
127
+ constructor(connection, context) {
128
+ this.context = context;
129
+ this.name = connection.name;
130
+ this.connection = connection;
131
+ const bullConnection = resolveBullConnection(connection);
132
+ this.bullConnection = bullConnection;
133
+ this.managedRedisConnection = bullConnection instanceof Redis || bullConnection instanceof Redis.Cluster ? bullConnection : void 0;
134
+ }
135
+ name;
136
+ driver = "redis";
137
+ mode = "async";
138
+ connection;
139
+ bullConnection;
140
+ managedRedisConnection;
141
+ queues = /* @__PURE__ */ new Map();
142
+ workers = /* @__PURE__ */ new Map();
143
+ reservations = /* @__PURE__ */ new Map();
144
+ queueCursor = 0;
145
+ getQueue(queueName) {
146
+ const cached = this.queues.get(queueName);
147
+ if (cached) {
148
+ return cached;
149
+ }
150
+ const queue = new BullQueue(queueName, {
151
+ connection: this.bullConnection,
152
+ defaultJobOptions: {
153
+ removeOnComplete: true,
154
+ removeOnFail: true
155
+ }
156
+ });
157
+ this.queues.set(queueName, queue);
158
+ return queue;
159
+ }
160
+ async getWorker(queueName) {
161
+ const cached = this.workers.get(queueName);
162
+ if (cached) {
163
+ return cached;
164
+ }
165
+ const worker = new BullWorker(
166
+ queueName,
167
+ null,
168
+ {
169
+ autorun: false,
170
+ concurrency: 1,
171
+ connection: this.bullConnection,
172
+ drainDelay: this.connection.blockFor,
173
+ lockDuration: this.connection.retryAfter * 1e3,
174
+ removeOnComplete: { count: 0 },
175
+ removeOnFail: { count: 0 }
176
+ }
177
+ );
178
+ await worker.waitUntilReady();
179
+ this.workers.set(queueName, worker);
180
+ return worker;
181
+ }
182
+ normalizeQueueNames(queueNames) {
183
+ if (!queueNames || queueNames.length === 0) {
184
+ return [.../* @__PURE__ */ new Set([
185
+ this.connection.queue,
186
+ ...this.queues.keys(),
187
+ ...this.workers.keys()
188
+ ])];
189
+ }
190
+ return [...new Set(queueNames)];
191
+ }
192
+ rotateQueueNames(queueNames) {
193
+ if (queueNames.length <= 1) {
194
+ return queueNames;
195
+ }
196
+ const offset = this.queueCursor % queueNames.length;
197
+ this.queueCursor = (this.queueCursor + 1) % queueNames.length;
198
+ return Object.freeze([
199
+ ...queueNames.slice(offset),
200
+ ...queueNames.slice(0, offset)
201
+ ]);
202
+ }
203
+ createReservedJob(job, token, queueName) {
204
+ if (!job.id) {
205
+ throw new Error("BullMQ returned a reserved job without an id.");
206
+ }
207
+ if (!isQueueEnvelope(job.data)) {
208
+ throw new Error(`BullMQ returned a malformed payload for job "${job.id}".`);
209
+ }
210
+ const attempts = resolveAttempts(job);
211
+ const envelope = Object.freeze({
212
+ id: job.id,
213
+ name: job.data.name,
214
+ connection: job.data.connection,
215
+ queue: job.data.queue || queueName,
216
+ payload: job.data.payload,
217
+ attempts,
218
+ maxAttempts: job.data.maxAttempts,
219
+ ...typeof job.data.availableAt === "number" ? { availableAt: job.data.availableAt } : {},
220
+ createdAt: job.data.createdAt
221
+ });
222
+ this.reservations.set(token, {
223
+ job,
224
+ token
225
+ });
226
+ return {
227
+ reservationId: token,
228
+ envelope,
229
+ reservedAt: Date.now()
230
+ };
231
+ }
232
+ getReservation(reserved) {
233
+ const reservation = this.reservations.get(reserved.reservationId);
234
+ if (!reservation) {
235
+ throw new Error(`Queue reservation "${reserved.reservationId}" is not active.`);
236
+ }
237
+ return reservation;
238
+ }
239
+ async settleReservation(reserved, action, callback) {
240
+ const reservation = this.getReservation(reserved);
241
+ try {
242
+ await callback(reservation);
243
+ } catch (error) {
244
+ throw wrapRedisError(this.name, action, error);
245
+ } finally {
246
+ this.reservations.delete(reserved.reservationId);
247
+ }
248
+ }
249
+ async dispatch(job) {
250
+ try {
251
+ const delay = typeof job.availableAt === "number" ? Math.max(job.availableAt - Date.now(), 0) : void 0;
252
+ const queued = await this.getQueue(job.queue).add(job.name, job, {
253
+ attempts: job.maxAttempts,
254
+ ...typeof delay === "number" ? { delay } : {},
255
+ jobId: job.id,
256
+ removeOnComplete: true,
257
+ removeOnFail: true,
258
+ timestamp: job.createdAt
259
+ });
260
+ return {
261
+ jobId: queued.id ?? job.id,
262
+ synchronous: false
263
+ };
264
+ } catch (error) {
265
+ throw wrapRedisError(this.name, "enqueue job", error);
266
+ }
267
+ }
268
+ async reserve(input) {
269
+ try {
270
+ const queueNames = this.rotateQueueNames(this.normalizeQueueNames(input.queueNames));
271
+ for (const queueName of queueNames) {
272
+ const token2 = `${input.workerId}:${randomUUID()}`;
273
+ const worker2 = await this.getWorker(queueName);
274
+ const job2 = await worker2.getNextJob(token2, { block: false });
275
+ if (job2) {
276
+ return this.createReservedJob(job2, token2, queueName);
277
+ }
278
+ }
279
+ const [blockingQueue] = queueNames;
280
+ if (!blockingQueue || this.connection.blockFor <= 0) {
281
+ return null;
282
+ }
283
+ const token = `${input.workerId}:${randomUUID()}`;
284
+ const worker = await this.getWorker(blockingQueue);
285
+ const job = await worker.getNextJob(token, { block: true });
286
+ if (!job) {
287
+ return null;
288
+ }
289
+ return this.createReservedJob(job, token, blockingQueue);
290
+ } catch (error) {
291
+ throw wrapRedisError(this.name, "reserve job", error);
292
+ }
293
+ }
294
+ async acknowledge(job) {
295
+ await this.settleReservation(job, "acknowledge job", async (reservation) => {
296
+ await reservation.job.moveToCompleted(null, reservation.token, false);
297
+ });
298
+ }
299
+ async release(job, options) {
300
+ await this.settleReservation(job, "release job", async (reservation) => {
301
+ if (typeof options?.delaySeconds === "number" && options.delaySeconds > 0) {
302
+ await reservation.job.moveToDelayed(Date.now() + options.delaySeconds * 1e3, reservation.token);
303
+ return;
304
+ }
305
+ await reservation.job.moveToWait(reservation.token);
306
+ });
307
+ }
308
+ async delete(job) {
309
+ await this.settleReservation(job, "delete job", async (reservation) => {
310
+ reservation.job.discard();
311
+ await reservation.job.moveToFailed(new Error("[Holo Queue] Job deleted."), reservation.token, false);
312
+ });
313
+ }
314
+ async clear(input) {
315
+ try {
316
+ const queueNames = this.normalizeQueueNames(input?.queueNames);
317
+ let cleared = 0;
318
+ for (const queueName of queueNames) {
319
+ const queue = this.getQueue(queueName);
320
+ cleared += await queue.getJobCountByTypes("wait", "waiting", "paused", "prioritized", "delayed");
321
+ await queue.drain(true);
322
+ }
323
+ return cleared;
324
+ } catch (error) {
325
+ throw wrapRedisError(this.name, "clear queued jobs", error);
326
+ }
327
+ }
328
+ async close() {
329
+ const resources = [
330
+ ...this.workers.values(),
331
+ ...this.queues.values()
332
+ ];
333
+ this.reservations.clear();
334
+ this.workers.clear();
335
+ this.queues.clear();
336
+ let closeRejection;
337
+ try {
338
+ const results = await Promise.allSettled(resources.map(async (resource) => {
339
+ if (resource instanceof BullWorker) {
340
+ await resource.close(true);
341
+ return;
342
+ }
343
+ await resource.close();
344
+ }));
345
+ closeRejection = results.find((result) => result.status === "rejected");
346
+ } finally {
347
+ if (this.managedRedisConnection) {
348
+ try {
349
+ await this.managedRedisConnection.quit();
350
+ } catch {
351
+ this.managedRedisConnection.disconnect();
352
+ }
353
+ }
354
+ }
355
+ if (closeRejection) {
356
+ throw wrapRedisError(this.name, "close driver", closeRejection.reason);
357
+ }
358
+ }
359
+ };
360
+ var redisQueueDriverFactory = {
361
+ driver: "redis",
362
+ create(connection, context) {
363
+ return new RedisQueueDriver(connection, context);
364
+ }
365
+ };
366
+ var redisQueueDriverInternals = {
367
+ isQueueEnvelope,
368
+ normalizeRedisErrorMessage,
369
+ parseClusterNodeUrl,
370
+ resolveAttempts,
371
+ resolveBullConnection,
372
+ resolveBullConnectionOptions,
373
+ resolveClusterStartupNodes,
374
+ toRedisSocketPath,
375
+ wrapRedisError
376
+ };
377
+ export {
378
+ RedisQueueDriver,
379
+ RedisQueueDriverError,
380
+ redisQueueDriverFactory,
381
+ redisQueueDriverInternals
382
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@holo-js/queue-redis",
3
+ "version": "0.1.3",
4
+ "description": "Holo-JS Framework - Redis queue driver",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.mjs",
11
+ "default": "./dist/index.mjs"
12
+ }
13
+ },
14
+ "main": "./dist/index.mjs",
15
+ "types": "./dist/index.d.ts",
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "stub": "tsup",
22
+ "typecheck": "tsc -p tsconfig.json --noEmit",
23
+ "test": "vitest --run"
24
+ },
25
+ "peerDependencies": {
26
+ "@holo-js/queue": "^0.1.3"
27
+ },
28
+ "dependencies": {
29
+ "bullmq": "^5.71.0",
30
+ "ioredis": "catalog:",
31
+ "tslib": "^2.8.1"
32
+ },
33
+ "devDependencies": {
34
+ "@holo-js/queue": "workspace:*",
35
+ "@types/node": "^22.10.2",
36
+ "tsup": "^8.3.5",
37
+ "typescript": "^5.7.2",
38
+ "vitest": "^2.1.8"
39
+ }
40
+ }