@parsrun/queue 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,703 @@
1
+ // src/types.ts
2
+ import {
3
+ type,
4
+ jobStatus,
5
+ job,
6
+ jobOptions,
7
+ addJobRequest,
8
+ jobProgressUpdate,
9
+ queueStats,
10
+ queueListOptions,
11
+ redisQueueConfig,
12
+ workerOptions,
13
+ queueConfig
14
+ } from "@parsrun/types";
15
+ var QueueError = class extends Error {
16
+ constructor(message, code, cause) {
17
+ super(message);
18
+ this.code = code;
19
+ this.cause = cause;
20
+ this.name = "QueueError";
21
+ }
22
+ };
23
+ var QueueErrorCodes = {
24
+ SEND_FAILED: "SEND_FAILED",
25
+ RECEIVE_FAILED: "RECEIVE_FAILED",
26
+ ACK_FAILED: "ACK_FAILED",
27
+ INVALID_CONFIG: "INVALID_CONFIG",
28
+ QUEUE_FULL: "QUEUE_FULL",
29
+ MESSAGE_NOT_FOUND: "MESSAGE_NOT_FOUND",
30
+ NOT_IMPLEMENTED: "NOT_IMPLEMENTED"
31
+ };
32
+
33
+ // src/adapters/memory.ts
34
+ var MemoryQueueAdapter = class {
35
+ type = "memory";
36
+ name;
37
+ messages = [];
38
+ inFlight = /* @__PURE__ */ new Map();
39
+ processedIds = /* @__PURE__ */ new Set();
40
+ maxSize;
41
+ visibilityTimeout;
42
+ messageCounter = 0;
43
+ isConsuming = false;
44
+ consumeInterval = null;
45
+ constructor(config) {
46
+ this.name = config.name;
47
+ this.maxSize = config.maxSize ?? Infinity;
48
+ this.visibilityTimeout = config.visibilityTimeout ?? 30;
49
+ }
50
+ generateId() {
51
+ this.messageCounter++;
52
+ return `msg-${Date.now()}-${this.messageCounter}`;
53
+ }
54
+ async send(body, options) {
55
+ if (this.messages.length >= this.maxSize) {
56
+ throw new QueueError(
57
+ `Queue ${this.name} is full`,
58
+ QueueErrorCodes.QUEUE_FULL
59
+ );
60
+ }
61
+ if (options?.deduplicationId && this.processedIds.has(options.deduplicationId)) {
62
+ return `dedup-${options.deduplicationId}`;
63
+ }
64
+ const id = this.generateId();
65
+ const now = Date.now();
66
+ const visibleAt = options?.delaySeconds ? now + options.delaySeconds * 1e3 : now;
67
+ const message = {
68
+ id,
69
+ body,
70
+ timestamp: /* @__PURE__ */ new Date(),
71
+ attempts: 0,
72
+ visibleAt,
73
+ deduplicationId: options?.deduplicationId,
74
+ metadata: options?.metadata
75
+ };
76
+ if (options?.priority !== void 0 && options.priority > 0) {
77
+ const insertIndex = this.messages.findIndex(
78
+ (m) => m.metadata?.["priority"] ?? 0 < (options.priority ?? 0)
79
+ );
80
+ if (insertIndex === -1) {
81
+ this.messages.push(message);
82
+ } else {
83
+ this.messages.splice(insertIndex, 0, message);
84
+ }
85
+ } else {
86
+ this.messages.push(message);
87
+ }
88
+ if (options?.deduplicationId) {
89
+ this.processedIds.add(options.deduplicationId);
90
+ }
91
+ return id;
92
+ }
93
+ async sendBatch(messages) {
94
+ const messageIds = [];
95
+ const errors = [];
96
+ let successful = 0;
97
+ let failed = 0;
98
+ for (let i = 0; i < messages.length; i++) {
99
+ const msg = messages[i];
100
+ if (!msg) continue;
101
+ try {
102
+ const id = await this.send(msg.body, msg.options);
103
+ messageIds.push(id);
104
+ successful++;
105
+ } catch (err) {
106
+ failed++;
107
+ errors.push({
108
+ index: i,
109
+ error: err instanceof Error ? err.message : "Unknown error"
110
+ });
111
+ }
112
+ }
113
+ return {
114
+ total: messages.length,
115
+ successful,
116
+ failed,
117
+ messageIds,
118
+ errors
119
+ };
120
+ }
121
+ async receive(maxMessages = 10, visibilityTimeoutOverride) {
122
+ const now = Date.now();
123
+ const timeout = (visibilityTimeoutOverride ?? this.visibilityTimeout) * 1e3;
124
+ const result = [];
125
+ const visibleMessages = [];
126
+ const remainingMessages = [];
127
+ for (const msg of this.messages) {
128
+ if (msg.visibleAt <= now && visibleMessages.length < maxMessages) {
129
+ visibleMessages.push(msg);
130
+ } else {
131
+ remainingMessages.push(msg);
132
+ }
133
+ }
134
+ this.messages = remainingMessages;
135
+ for (const msg of visibleMessages) {
136
+ msg.attempts++;
137
+ msg.visibleAt = now + timeout;
138
+ this.inFlight.set(msg.id, msg);
139
+ result.push({
140
+ id: msg.id,
141
+ body: msg.body,
142
+ timestamp: msg.timestamp,
143
+ attempts: msg.attempts,
144
+ metadata: msg.metadata
145
+ });
146
+ }
147
+ return result;
148
+ }
149
+ async ack(messageId) {
150
+ const message = this.inFlight.get(messageId);
151
+ if (!message) {
152
+ throw new QueueError(
153
+ `Message ${messageId} not found in flight`,
154
+ QueueErrorCodes.MESSAGE_NOT_FOUND
155
+ );
156
+ }
157
+ this.inFlight.delete(messageId);
158
+ }
159
+ async ackBatch(messageIds) {
160
+ for (const id of messageIds) {
161
+ this.inFlight.delete(id);
162
+ }
163
+ }
164
+ async nack(messageId, delaySeconds) {
165
+ const message = this.inFlight.get(messageId);
166
+ if (!message) {
167
+ throw new QueueError(
168
+ `Message ${messageId} not found in flight`,
169
+ QueueErrorCodes.MESSAGE_NOT_FOUND
170
+ );
171
+ }
172
+ this.inFlight.delete(messageId);
173
+ const now = Date.now();
174
+ message.visibleAt = delaySeconds ? now + delaySeconds * 1e3 : now;
175
+ this.messages.push(message);
176
+ }
177
+ async consume(handler, options) {
178
+ if (this.isConsuming) {
179
+ return;
180
+ }
181
+ this.isConsuming = true;
182
+ const batchSize = options?.batchSize ?? 10;
183
+ const pollingInterval = options?.pollingInterval ?? 1e3;
184
+ const visibilityTimeout = options?.visibilityTimeout ?? this.visibilityTimeout;
185
+ const maxRetries = options?.maxRetries ?? 3;
186
+ const concurrency = options?.concurrency ?? 1;
187
+ const processMessages = async () => {
188
+ if (!this.isConsuming) return;
189
+ const messages = await this.receive(batchSize, visibilityTimeout);
190
+ for (let i = 0; i < messages.length; i += concurrency) {
191
+ const batch = messages.slice(i, i + concurrency);
192
+ await Promise.all(
193
+ batch.map(async (msg) => {
194
+ try {
195
+ await handler(msg);
196
+ await this.ack(msg.id);
197
+ } catch (err) {
198
+ if (msg.attempts >= maxRetries) {
199
+ await this.ack(msg.id);
200
+ console.error(
201
+ `[Queue ${this.name}] Message ${msg.id} exceeded max retries, dropped`
202
+ );
203
+ } else {
204
+ await this.nack(msg.id, 5);
205
+ }
206
+ }
207
+ })
208
+ );
209
+ }
210
+ };
211
+ this.consumeInterval = setInterval(processMessages, pollingInterval);
212
+ await processMessages();
213
+ }
214
+ async stopConsuming() {
215
+ this.isConsuming = false;
216
+ if (this.consumeInterval) {
217
+ clearInterval(this.consumeInterval);
218
+ this.consumeInterval = null;
219
+ }
220
+ }
221
+ async getStats() {
222
+ const now = Date.now();
223
+ for (const [id, msg] of this.inFlight) {
224
+ if (msg.visibleAt <= now) {
225
+ this.inFlight.delete(id);
226
+ this.messages.push(msg);
227
+ }
228
+ }
229
+ return {
230
+ messageCount: this.messages.length,
231
+ inFlightCount: this.inFlight.size
232
+ };
233
+ }
234
+ async purge() {
235
+ this.messages = [];
236
+ this.inFlight.clear();
237
+ }
238
+ async close() {
239
+ await this.stopConsuming();
240
+ await this.purge();
241
+ this.processedIds.clear();
242
+ }
243
+ };
244
+ function createMemoryQueueAdapter(config) {
245
+ return new MemoryQueueAdapter(config);
246
+ }
247
+
248
+ // src/adapters/cloudflare.ts
249
+ var CloudflareQueueAdapter = class {
250
+ type = "cloudflare";
251
+ name = "cloudflare-queue";
252
+ queue;
253
+ constructor(config) {
254
+ this.queue = config.queue;
255
+ }
256
+ async send(body, options) {
257
+ try {
258
+ await this.queue.send(body, {
259
+ delaySeconds: options?.delaySeconds
260
+ });
261
+ return `cf-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
262
+ } catch (err) {
263
+ throw new QueueError(
264
+ `Cloudflare Queue send failed: ${err instanceof Error ? err.message : "Unknown error"}`,
265
+ QueueErrorCodes.SEND_FAILED,
266
+ err
267
+ );
268
+ }
269
+ }
270
+ async sendBatch(messages) {
271
+ try {
272
+ const batchMessages = messages.map((m) => ({
273
+ body: m.body,
274
+ delaySeconds: m.options?.delaySeconds
275
+ }));
276
+ await this.queue.sendBatch(batchMessages);
277
+ const messageIds = messages.map(
278
+ () => `cf-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
279
+ );
280
+ return {
281
+ total: messages.length,
282
+ successful: messages.length,
283
+ failed: 0,
284
+ messageIds,
285
+ errors: []
286
+ };
287
+ } catch (err) {
288
+ throw new QueueError(
289
+ `Cloudflare Queue batch send failed: ${err instanceof Error ? err.message : "Unknown error"}`,
290
+ QueueErrorCodes.SEND_FAILED,
291
+ err
292
+ );
293
+ }
294
+ }
295
+ // Cloudflare Queues are push-based, so receive is not applicable
296
+ // Messages are delivered to queue handlers via Workers
297
+ };
298
+ var CloudflareQueueProcessor = class {
299
+ /**
300
+ * Process a batch of messages from Cloudflare Queues
301
+ */
302
+ async processBatch(batch, handler, options) {
303
+ let processed = 0;
304
+ let failed = 0;
305
+ for (const msg of batch.messages) {
306
+ try {
307
+ const queueMessage = {
308
+ id: msg.id,
309
+ body: msg.body,
310
+ timestamp: msg.timestamp,
311
+ attempts: msg.attempts
312
+ };
313
+ await handler(queueMessage);
314
+ if (!options?.ackAll) {
315
+ msg.ack();
316
+ }
317
+ processed++;
318
+ } catch (err) {
319
+ failed++;
320
+ if (options?.retryAllOnFailure) {
321
+ batch.retryAll();
322
+ return { processed, failed: batch.messages.length };
323
+ }
324
+ msg.retry();
325
+ }
326
+ }
327
+ if (options?.ackAll && failed === 0) {
328
+ batch.ackAll();
329
+ }
330
+ return { processed, failed };
331
+ }
332
+ /**
333
+ * Convert a Cloudflare message to QueueMessage format
334
+ */
335
+ toQueueMessage(msg) {
336
+ return {
337
+ id: msg.id,
338
+ body: msg.body,
339
+ timestamp: msg.timestamp,
340
+ attempts: msg.attempts
341
+ };
342
+ }
343
+ };
344
+ function createCloudflareQueueAdapter(config) {
345
+ return new CloudflareQueueAdapter(config);
346
+ }
347
+ function createCloudflareQueueProcessor() {
348
+ return new CloudflareQueueProcessor();
349
+ }
350
+
351
+ // src/adapters/qstash.ts
352
+ var QStashAdapter = class {
353
+ type = "qstash";
354
+ name = "qstash";
355
+ token;
356
+ destinationUrl;
357
+ baseUrl = "https://qstash.upstash.io/v2";
358
+ constructor(config) {
359
+ this.token = config.token;
360
+ this.destinationUrl = config.destinationUrl;
361
+ }
362
+ async send(body, options) {
363
+ try {
364
+ const headers = {
365
+ Authorization: `Bearer ${this.token}`,
366
+ "Content-Type": "application/json"
367
+ };
368
+ if (options?.delaySeconds) {
369
+ headers["Upstash-Delay"] = `${options.delaySeconds}s`;
370
+ }
371
+ if (options?.deduplicationId) {
372
+ headers["Upstash-Deduplication-Id"] = options.deduplicationId;
373
+ }
374
+ if (options?.metadata) {
375
+ for (const [key, value] of Object.entries(options.metadata)) {
376
+ if (typeof value === "string") {
377
+ headers[`Upstash-Forward-${key}`] = value;
378
+ }
379
+ }
380
+ }
381
+ const response = await fetch(
382
+ `${this.baseUrl}/publish/${encodeURIComponent(this.destinationUrl)}`,
383
+ {
384
+ method: "POST",
385
+ headers,
386
+ body: JSON.stringify(body)
387
+ }
388
+ );
389
+ if (!response.ok) {
390
+ const error = await response.text();
391
+ throw new Error(`QStash API error: ${error}`);
392
+ }
393
+ const result = await response.json();
394
+ return result.messageId;
395
+ } catch (err) {
396
+ throw new QueueError(
397
+ `QStash send failed: ${err instanceof Error ? err.message : "Unknown error"}`,
398
+ QueueErrorCodes.SEND_FAILED,
399
+ err
400
+ );
401
+ }
402
+ }
403
+ async sendBatch(messages) {
404
+ try {
405
+ const batchMessages = messages.map((m) => {
406
+ const headers = {
407
+ "Content-Type": "application/json"
408
+ };
409
+ if (m.options?.delaySeconds) {
410
+ headers["Upstash-Delay"] = `${m.options.delaySeconds}s`;
411
+ }
412
+ if (m.options?.deduplicationId) {
413
+ headers["Upstash-Deduplication-Id"] = m.options.deduplicationId;
414
+ }
415
+ return {
416
+ destination: this.destinationUrl,
417
+ headers,
418
+ body: JSON.stringify(m.body)
419
+ };
420
+ });
421
+ const response = await fetch(`${this.baseUrl}/batch`, {
422
+ method: "POST",
423
+ headers: {
424
+ Authorization: `Bearer ${this.token}`,
425
+ "Content-Type": "application/json"
426
+ },
427
+ body: JSON.stringify(batchMessages)
428
+ });
429
+ if (!response.ok) {
430
+ const error = await response.text();
431
+ throw new Error(`QStash API error: ${error}`);
432
+ }
433
+ const results = await response.json();
434
+ const messageIds = [];
435
+ const errors = [];
436
+ let successful = 0;
437
+ let failed = 0;
438
+ for (let i = 0; i < results.length; i++) {
439
+ const result = results[i];
440
+ if (result?.messageId) {
441
+ messageIds.push(result.messageId);
442
+ successful++;
443
+ } else {
444
+ failed++;
445
+ errors.push({
446
+ index: i,
447
+ error: result?.error ?? "Unknown error"
448
+ });
449
+ }
450
+ }
451
+ return {
452
+ total: messages.length,
453
+ successful,
454
+ failed,
455
+ messageIds,
456
+ errors
457
+ };
458
+ } catch (err) {
459
+ throw new QueueError(
460
+ `QStash batch send failed: ${err instanceof Error ? err.message : "Unknown error"}`,
461
+ QueueErrorCodes.SEND_FAILED,
462
+ err
463
+ );
464
+ }
465
+ }
466
+ /**
467
+ * Schedule a message for future delivery
468
+ */
469
+ async schedule(body, cronExpression, options) {
470
+ try {
471
+ const headers = {
472
+ Authorization: `Bearer ${this.token}`,
473
+ "Content-Type": "application/json",
474
+ "Upstash-Cron": cronExpression
475
+ };
476
+ if (options?.deduplicationId) {
477
+ headers["Upstash-Deduplication-Id"] = options.deduplicationId;
478
+ }
479
+ const response = await fetch(
480
+ `${this.baseUrl}/schedules/${encodeURIComponent(this.destinationUrl)}`,
481
+ {
482
+ method: "POST",
483
+ headers,
484
+ body: JSON.stringify(body)
485
+ }
486
+ );
487
+ if (!response.ok) {
488
+ const error = await response.text();
489
+ throw new Error(`QStash API error: ${error}`);
490
+ }
491
+ const result = await response.json();
492
+ return result.scheduleId;
493
+ } catch (err) {
494
+ throw new QueueError(
495
+ `QStash schedule failed: ${err instanceof Error ? err.message : "Unknown error"}`,
496
+ QueueErrorCodes.SEND_FAILED,
497
+ err
498
+ );
499
+ }
500
+ }
501
+ /**
502
+ * Delete a scheduled message
503
+ */
504
+ async deleteSchedule(scheduleId) {
505
+ try {
506
+ const response = await fetch(`${this.baseUrl}/schedules/${scheduleId}`, {
507
+ method: "DELETE",
508
+ headers: {
509
+ Authorization: `Bearer ${this.token}`
510
+ }
511
+ });
512
+ if (!response.ok) {
513
+ const error = await response.text();
514
+ throw new Error(`QStash API error: ${error}`);
515
+ }
516
+ } catch (err) {
517
+ throw new QueueError(
518
+ `QStash delete schedule failed: ${err instanceof Error ? err.message : "Unknown error"}`,
519
+ QueueErrorCodes.SEND_FAILED,
520
+ err
521
+ );
522
+ }
523
+ }
524
+ /**
525
+ * List all schedules
526
+ */
527
+ async listSchedules() {
528
+ try {
529
+ const response = await fetch(`${this.baseUrl}/schedules`, {
530
+ headers: {
531
+ Authorization: `Bearer ${this.token}`
532
+ }
533
+ });
534
+ if (!response.ok) {
535
+ const error = await response.text();
536
+ throw new Error(`QStash API error: ${error}`);
537
+ }
538
+ return await response.json();
539
+ } catch (err) {
540
+ throw new QueueError(
541
+ `QStash list schedules failed: ${err instanceof Error ? err.message : "Unknown error"}`,
542
+ QueueErrorCodes.SEND_FAILED,
543
+ err
544
+ );
545
+ }
546
+ }
547
+ };
548
+ var QStashReceiver = class {
549
+ currentSigningKey;
550
+ nextSigningKey;
551
+ constructor(config) {
552
+ this.currentSigningKey = config.currentSigningKey;
553
+ this.nextSigningKey = config.nextSigningKey;
554
+ }
555
+ /**
556
+ * Verify a request from QStash and extract the message
557
+ */
558
+ async verify(request) {
559
+ const signature = request.headers.get("Upstash-Signature");
560
+ if (!signature) {
561
+ return null;
562
+ }
563
+ const body = await request.text();
564
+ const isValid = await this.verifySignature(body, signature, this.currentSigningKey) || await this.verifySignature(body, signature, this.nextSigningKey);
565
+ if (!isValid) {
566
+ return null;
567
+ }
568
+ const messageId = request.headers.get("Upstash-Message-Id") ?? `qstash-${Date.now()}`;
569
+ const retryCount = parseInt(
570
+ request.headers.get("Upstash-Retried") ?? "0",
571
+ 10
572
+ );
573
+ let parsedBody;
574
+ try {
575
+ parsedBody = JSON.parse(body);
576
+ } catch {
577
+ parsedBody = body;
578
+ }
579
+ return {
580
+ id: messageId,
581
+ body: parsedBody,
582
+ timestamp: /* @__PURE__ */ new Date(),
583
+ attempts: retryCount + 1
584
+ };
585
+ }
586
+ async verifySignature(body, signature, key) {
587
+ try {
588
+ const parts = signature.split(".");
589
+ if (parts.length !== 3) {
590
+ return false;
591
+ }
592
+ const [headerB64, payloadB64, signatureB64] = parts;
593
+ if (!headerB64 || !payloadB64 || !signatureB64) {
594
+ return false;
595
+ }
596
+ const encoder = new TextEncoder();
597
+ const keyData = encoder.encode(key);
598
+ const cryptoKey = await crypto.subtle.importKey(
599
+ "raw",
600
+ keyData,
601
+ { name: "HMAC", hash: "SHA-256" },
602
+ false,
603
+ ["verify"]
604
+ );
605
+ const signatureData = this.base64UrlDecode(signatureB64);
606
+ const dataToVerify = encoder.encode(`${headerB64}.${payloadB64}`);
607
+ const isValid = await crypto.subtle.verify(
608
+ "HMAC",
609
+ cryptoKey,
610
+ signatureData,
611
+ dataToVerify
612
+ );
613
+ if (!isValid) {
614
+ return false;
615
+ }
616
+ const payload = JSON.parse(atob(payloadB64));
617
+ const now = Math.floor(Date.now() / 1e3);
618
+ if (payload.exp && payload.exp < now) {
619
+ return false;
620
+ }
621
+ if (payload.nbf && payload.nbf > now) {
622
+ return false;
623
+ }
624
+ if (payload.body) {
625
+ const bodyHash = await this.sha256(body);
626
+ const expectedHash = this.base64UrlEncode(
627
+ new Uint8Array(
628
+ atob(payload.body).split("").map((c) => c.charCodeAt(0))
629
+ )
630
+ );
631
+ if (bodyHash !== expectedHash && payload.body !== bodyHash) {
632
+ const directHash = await this.sha256Base64(body);
633
+ if (directHash !== payload.body) {
634
+ return false;
635
+ }
636
+ }
637
+ }
638
+ return true;
639
+ } catch {
640
+ return false;
641
+ }
642
+ }
643
+ base64UrlDecode(str) {
644
+ const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
645
+ const padding = (4 - base64.length % 4) % 4;
646
+ const padded = base64 + "=".repeat(padding);
647
+ const binary = atob(padded);
648
+ const bytes = new Uint8Array(binary.length);
649
+ for (let i = 0; i < binary.length; i++) {
650
+ bytes[i] = binary.charCodeAt(i);
651
+ }
652
+ return bytes;
653
+ }
654
+ base64UrlEncode(data) {
655
+ let binary = "";
656
+ for (let i = 0; i < data.length; i++) {
657
+ const byte = data[i];
658
+ if (byte !== void 0) {
659
+ binary += String.fromCharCode(byte);
660
+ }
661
+ }
662
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
663
+ }
664
+ async sha256(message) {
665
+ const encoder = new TextEncoder();
666
+ const data = encoder.encode(message);
667
+ const hash = await crypto.subtle.digest("SHA-256", data);
668
+ return this.base64UrlEncode(new Uint8Array(hash));
669
+ }
670
+ async sha256Base64(message) {
671
+ const encoder = new TextEncoder();
672
+ const data = encoder.encode(message);
673
+ const hash = await crypto.subtle.digest("SHA-256", data);
674
+ let binary = "";
675
+ const bytes = new Uint8Array(hash);
676
+ for (let i = 0; i < bytes.length; i++) {
677
+ const byte = bytes[i];
678
+ if (byte !== void 0) {
679
+ binary += String.fromCharCode(byte);
680
+ }
681
+ }
682
+ return btoa(binary);
683
+ }
684
+ };
685
+ function createQStashAdapter(config) {
686
+ return new QStashAdapter(config);
687
+ }
688
+ function createQStashReceiver(config) {
689
+ return new QStashReceiver(config);
690
+ }
691
+ export {
692
+ CloudflareQueueAdapter,
693
+ CloudflareQueueProcessor,
694
+ MemoryQueueAdapter,
695
+ QStashAdapter,
696
+ QStashReceiver,
697
+ createCloudflareQueueAdapter,
698
+ createCloudflareQueueProcessor,
699
+ createMemoryQueueAdapter,
700
+ createQStashAdapter,
701
+ createQStashReceiver
702
+ };
703
+ //# sourceMappingURL=index.js.map