@vercel/queue 0.0.0-alpha.1

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/README.md ADDED
@@ -0,0 +1,670 @@
1
+ # VQS - Vercel Queue Service Client
2
+
3
+ A TypeScript client library for interacting with the Vercel Queue Service API with customizable serialization/deserialization (transport) support, including **streaming support** for memory-efficient processing of large payloads.
4
+
5
+ ## Features
6
+
7
+ - **Generic Payload Support**: Send and receive any type of data with type safety
8
+ - **Customizable Serialization**: Use built-in transports (JSON, Buffer, Stream) or create your own
9
+ - **Streaming Support**: Handle large payloads without loading them entirely into memory
10
+ - **Pub/Sub Pattern**: Topic-based messaging with consumer groups
11
+ - **Type Safety**: Full TypeScript support with generic types
12
+ - **Automatic Retries**: Built-in visibility timeout management
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @vercel/queue
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ```typescript
23
+ import { VQSClient, createTopic, JsonTransport } from "@vercel/queue";
24
+
25
+ const client = new VQSClient({
26
+ token: "your-vercel-oidc-token",
27
+ });
28
+
29
+ // Create a topic with JSON serialization (default)
30
+ const topic = createTopic<{ message: string; timestamp: number }>(
31
+ client,
32
+ "my-topic",
33
+ );
34
+
35
+ // Publish a message
36
+ await topic.publish({
37
+ message: "Hello, World!",
38
+ timestamp: Date.now(),
39
+ });
40
+
41
+ // Create a consumer group
42
+ const consumer = topic.consumerGroup("my-processors");
43
+
44
+ // Process messages continuously with cancellation support
45
+ const controller = new AbortController();
46
+
47
+ // Start processing (blocks until aborted or error)
48
+ try {
49
+ await consumer.subscribe(controller.signal, async (message) => {
50
+ console.log("Received:", message.payload.message);
51
+ console.log("Timestamp:", new Date(message.payload.timestamp));
52
+ });
53
+ } catch (error) {
54
+ console.error("Processing stopped due to error:", error);
55
+ }
56
+
57
+ // Stop processing from elsewhere in your code
58
+ // controller.abort();
59
+ ```
60
+
61
+ ## Serialization (Transport) System
62
+
63
+ The VQS client supports customizable serialization through the `Transport` interface with **streaming support** for memory-efficient processing. Transport can be configured at the **topic level** when creating a topic, or at the **consumer group level** when creating a consumer group.
64
+
65
+ ### Built-in Transports
66
+
67
+ #### JsonTransport (Default)
68
+
69
+ Buffers data for JSON parsing - suitable for structured data that fits in memory.
70
+
71
+ ```typescript
72
+ import { JsonTransport, createTopic } from "@vercel/queue";
73
+
74
+ const topic = createTopic<{ data: any }>(
75
+ client,
76
+ "json-topic",
77
+ new JsonTransport(),
78
+ );
79
+ // or simply (JsonTransport is the default):
80
+ const topic = createTopic<{ data: any }>(client, "json-topic");
81
+ ```
82
+
83
+ #### BufferTransport
84
+
85
+ Buffers the entire payload into memory as a Buffer - suitable for binary data that fits in memory.
86
+
87
+ ```typescript
88
+ import { BufferTransport, createTopic } from "@vercel/queue";
89
+
90
+ const topic = createTopic<Buffer>(
91
+ client,
92
+ "binary-topic",
93
+ new BufferTransport(),
94
+ );
95
+ const binaryData = Buffer.from("Binary data", "utf8");
96
+ await topic.publish(binaryData);
97
+ ```
98
+
99
+ #### StreamTransport
100
+
101
+ **True streaming support** - passes ReadableStream directly without buffering. Ideal for large files and memory-efficient processing.
102
+
103
+ ```typescript
104
+ import { StreamTransport, createTopic } from "@vercel/queue";
105
+
106
+ const topic = createTopic<ReadableStream<Uint8Array>>(
107
+ client,
108
+ "streaming-topic",
109
+ new StreamTransport(),
110
+ );
111
+
112
+ // Send large file as stream without loading into memory
113
+ const fileStream = new ReadableStream<Uint8Array>({
114
+ start(controller) {
115
+ // Read file in chunks
116
+ for (const chunk of readFileInChunks("large-file.bin")) {
117
+ controller.enqueue(chunk);
118
+ }
119
+ controller.close();
120
+ },
121
+ });
122
+
123
+ await topic.publish(fileStream);
124
+ ```
125
+
126
+ ### Custom Transport
127
+
128
+ You can create your own serialization format by implementing the `Transport` interface:
129
+
130
+ ```typescript
131
+ import { Transport } from "@vercel/queue";
132
+
133
+ interface Transport<T = unknown> {
134
+ serialize(value: T): Buffer | ReadableStream<Uint8Array>;
135
+ deserialize(stream: ReadableStream<Uint8Array>): Promise<T>;
136
+ contentType: string;
137
+ }
138
+ ```
139
+
140
+ ### Choosing the Right Transport
141
+
142
+ | Use Case | Recommended Transport | Memory Usage | Performance |
143
+ | ---------------------- | --------------------- | ------------ | ----------- |
144
+ | Small JSON objects | `JsonTransport` | Low | High |
145
+ | Binary files < 100MB | `BufferTransport` | Medium | High |
146
+ | Large files > 100MB | `StreamTransport` | Very Low | Medium |
147
+ | Real-time data streams | `StreamTransport` | Very Low | High |
148
+ | Custom protocols | Custom implementation | Varies | Varies |
149
+
150
+ ## Complete Example: Video Processing Pipeline
151
+
152
+ Here's a comprehensive example showing a video processing pipeline that processes videos with FFmpeg and stores the results in Vercel Blob:
153
+
154
+ ```typescript
155
+ import { VQSClient, createTopic, StreamTransport } from "@vercel/queue";
156
+ import { spawn } from "child_process";
157
+ import ffmpeg from "ffmpeg-static";
158
+ import { put } from "@vercel/blob";
159
+
160
+ const client = new VQSClient({
161
+ token: process.env.VQS_TOKEN!,
162
+ });
163
+
164
+ // Input topic with unoptimized videos
165
+ const unoptimizedVideosTopic = createTopic<ReadableStream<Uint8Array>>(
166
+ client,
167
+ "unoptimized-videos",
168
+ new StreamTransport(),
169
+ );
170
+
171
+ // Output topic for optimized videos
172
+ const optimizedVideosTopic = createTopic<ReadableStream<Uint8Array>>(
173
+ client,
174
+ "optimized-videos",
175
+ new StreamTransport(),
176
+ );
177
+
178
+ // Step 1: Process videos with FFmpeg
179
+ const videoProcessor = unoptimizedVideosTopic.consumerGroup("processors");
180
+ const processingController = new AbortController();
181
+
182
+ try {
183
+ await videoProcessor.subscribe(
184
+ processingController.signal,
185
+ async (message) => {
186
+ const inputVideoStream = message.payload;
187
+ console.log("Processing video...");
188
+
189
+ if (!ffmpeg) {
190
+ throw new Error("FFmpeg not available");
191
+ }
192
+
193
+ // Create optimized video stream using FFmpeg
194
+ const optimizedStream = new ReadableStream<Uint8Array>({
195
+ start(controller) {
196
+ const ffmpegProcess = spawn(
197
+ ffmpeg,
198
+ [
199
+ "-i",
200
+ "pipe:0", // Input from stdin
201
+ "-c:v",
202
+ "libvpx-vp9", // Video codec
203
+ "-c:a",
204
+ "libopus", // Audio codec
205
+ "-crf",
206
+ "23", // Quality
207
+ "-f",
208
+ "webm", // Output format
209
+ "pipe:1", // Output to stdout
210
+ ],
211
+ { stdio: ["pipe", "pipe", "pipe"] },
212
+ );
213
+
214
+ // Pipe input stream to FFmpeg
215
+ const reader = inputVideoStream.getReader();
216
+ const pipeInput = async () => {
217
+ while (true) {
218
+ const { done, value } = await reader.read();
219
+ if (done) {
220
+ ffmpegProcess.stdin?.end();
221
+ break;
222
+ }
223
+ ffmpegProcess.stdin?.write(value);
224
+ }
225
+ };
226
+ pipeInput();
227
+
228
+ // Stream FFmpeg output
229
+ ffmpegProcess.stdout?.on("data", (chunk) => {
230
+ controller.enqueue(new Uint8Array(chunk));
231
+ });
232
+
233
+ ffmpegProcess.on("close", (code) => {
234
+ if (code === 0) {
235
+ controller.close();
236
+ } else {
237
+ controller.error(new Error(`FFmpeg failed with code ${code}`));
238
+ }
239
+ });
240
+ },
241
+ });
242
+
243
+ // Publish optimized video to next topic
244
+ await optimizedVideosTopic.publish(optimizedStream);
245
+ console.log("Video optimized and published");
246
+ },
247
+ );
248
+ } catch (error) {
249
+ console.error("Video processing error:", error);
250
+ }
251
+
252
+ // Step 2: Store optimized videos in Vercel Blob
253
+ const blobUploader = optimizedVideosTopic.consumerGroup("blob-uploaders");
254
+ const uploadController = new AbortController();
255
+
256
+ try {
257
+ await blobUploader.subscribe(uploadController.signal, async (message) => {
258
+ const optimizedVideo = message.payload;
259
+
260
+ // Upload to Vercel Blob storage
261
+ const filename = `optimized-${Date.now()}.webm`;
262
+ const blob = await put(filename, optimizedVideo, {
263
+ access: "public",
264
+ contentType: "video/webm",
265
+ });
266
+
267
+ console.log(`Video uploaded to blob: ${blob.url} (${blob.size} bytes)`);
268
+ });
269
+ } catch (error) {
270
+ console.error("Blob upload error:", error);
271
+ }
272
+
273
+ // Graceful shutdown
274
+ process.on("SIGINT", () => {
275
+ processingController.abort();
276
+ uploadController.abort();
277
+ });
278
+ ```
279
+
280
+ ## API Reference
281
+
282
+ ### VQSClient
283
+
284
+ ```typescript
285
+ const client = new VQSClient({
286
+ token: string;
287
+ baseUrl?: string; // defaults to 'https://@vercel/queue.vercel.sh'
288
+ });
289
+ ```
290
+
291
+ ### Topic
292
+
293
+ ```typescript
294
+ const topic = createTopic<T>(client, topicName, transport?);
295
+
296
+ // Publish a message (uses topic's transport)
297
+ await topic.publish(payload, options?);
298
+
299
+ // Create a consumer group (can override transport)
300
+ const consumer = topic.consumerGroup<U>(groupName, options?);
301
+ ```
302
+
303
+ ### ConsumerGroup
304
+
305
+ ```typescript
306
+ // Start continuous processing (blocks until signal is aborted or error occurs)
307
+ await consumer.subscribe(signal, handler, options?);
308
+
309
+ // Process a specific message by ID
310
+ await consumer.receiveMessage(messageId, handler);
311
+
312
+ // Process the next available message
313
+ await consumer.receiveNextMessage(handler);
314
+
315
+ // Handle a specific message by ID without payload
316
+ await consumer.handleMessage(messageId, handler);
317
+ ```
318
+
319
+ ### Message Handler
320
+
321
+ ```typescript
322
+ // Handler function signature
323
+ type MessageHandler<T> = (
324
+ message: Message<T>,
325
+ ) => Promise<MessageHandlerResult> | MessageHandlerResult;
326
+
327
+ // Handler result types
328
+ type MessageHandlerResult = void | MessageTimeoutResult;
329
+
330
+ interface MessageTimeoutResult {
331
+ timeoutSeconds: number; // seconds before message becomes available again
332
+ }
333
+ ```
334
+
335
+ ### Transport Interface
336
+
337
+ ```typescript
338
+ interface Transport<T = unknown> {
339
+ serialize(value: T): Buffer | ReadableStream<Uint8Array>;
340
+ deserialize(stream: ReadableStream<Uint8Array>): Promise<T>;
341
+ contentType: string;
342
+ }
343
+ ```
344
+
345
+ ### Callback Utilities
346
+
347
+ ```typescript
348
+ // Parse VQS callback request headers
349
+ function parseCallbackRequest(request: Request): CallbackMessageOptions;
350
+
351
+ // Callback options type
352
+ interface CallbackMessageOptions {
353
+ queueName: string;
354
+ consumerGroup: string;
355
+ messageId: string;
356
+ }
357
+
358
+ // Error thrown for invalid callback requests
359
+ class InvalidCallbackError extends Error;
360
+ ```
361
+
362
+ ## Examples
363
+
364
+ ### Basic JSON Processing
365
+
366
+ ```typescript
367
+ interface UserEvent {
368
+ userId: string;
369
+ action: string;
370
+ timestamp: number;
371
+ }
372
+
373
+ const userTopic = createTopic<UserEvent>(client, "user-events");
374
+
375
+ await userTopic.publish({
376
+ userId: "123",
377
+ action: "login",
378
+ timestamp: Date.now(),
379
+ });
380
+
381
+ const consumer = userTopic.consumerGroup("processors");
382
+ const controller = new AbortController();
383
+
384
+ try {
385
+ await consumer.subscribe(controller.signal, async (message) => {
386
+ console.log(
387
+ `User ${message.payload.userId} performed ${message.payload.action}`,
388
+ );
389
+ });
390
+ } catch (error) {
391
+ console.error("Processing error:", error);
392
+ }
393
+
394
+ // Stop processing when needed
395
+ // controller.abort();
396
+ ```
397
+
398
+ ### Processing Specific Messages by ID
399
+
400
+ ```typescript
401
+ const userTopic = createTopic<{ userId: string; action: string }>(
402
+ client,
403
+ "user-events",
404
+ );
405
+ const consumer = userTopic.consumerGroup("processors");
406
+
407
+ // Process a specific message if you know its ID
408
+ const messageId = "01234567-89ab-cdef-0123-456789abcdef";
409
+
410
+ try {
411
+ await consumer.receiveMessage(messageId, async (message) => {
412
+ console.log(`Processing specific message: ${message.messageId}`);
413
+ console.log(
414
+ `User ${message.payload.userId} performed ${message.payload.action}`,
415
+ );
416
+ });
417
+ console.log("Message processed successfully");
418
+ } catch (error) {
419
+ if (error.message.includes("not found or not available")) {
420
+ console.log("Message was already processed or does not exist");
421
+ } else if (error.message.includes("FIFO ordering violation")) {
422
+ console.log("FIFO queue requires processing messages in order");
423
+ } else {
424
+ console.error("Error processing message:", error);
425
+ }
426
+ }
427
+ ```
428
+
429
+ ### Processing Next Available Message
430
+
431
+ ```typescript
432
+ const workTopic = createTopic<{ taskType: string; data: any }>(
433
+ client,
434
+ "work-queue",
435
+ );
436
+ const worker = workTopic.consumerGroup("workers");
437
+
438
+ // Process the next available message (one-shot processing)
439
+ try {
440
+ await worker.receiveNextMessage(async (message) => {
441
+ console.log(`Processing task: ${message.payload.taskType}`);
442
+ await processTask(message.payload.taskType, message.payload.data);
443
+ });
444
+ console.log("Message processed successfully");
445
+ } catch (error) {
446
+ if (error instanceof QueueEmptyError) {
447
+ console.log("No messages available");
448
+ } else if (error instanceof MessageLockedError) {
449
+ console.log("Next message is locked (FIFO queue)");
450
+ if (error.retryAfter) {
451
+ console.log(`Retry after ${error.retryAfter} seconds`);
452
+ }
453
+ } else {
454
+ console.error("Error processing message:", error);
455
+ }
456
+ }
457
+
458
+ // You can also use it with timeout results
459
+ await worker.receiveNextMessage(async (message) => {
460
+ if (!canProcessTaskType(message.payload.taskType)) {
461
+ // Return timeout to retry later
462
+ return { timeoutSeconds: 60 };
463
+ }
464
+
465
+ await processTask(message.payload.taskType, message.payload.data);
466
+ });
467
+ ```
468
+
469
+ ### Timing Out Messages
470
+
471
+ ```typescript
472
+ const workTopic = createTopic<{ taskType: string; data: any }>(
473
+ client,
474
+ "work-queue",
475
+ );
476
+ const worker = workTopic.consumerGroup("workers");
477
+ const controller = new AbortController();
478
+
479
+ try {
480
+ await worker.subscribe(controller.signal, async (message) => {
481
+ const { taskType, data } = message.payload;
482
+
483
+ // Check if we can process this task type right now
484
+ if (taskType === "heavy-computation" && isSystemOverloaded()) {
485
+ // Return timeout to retry later (5 minutes)
486
+ return { timeoutSeconds: 300 };
487
+ }
488
+
489
+ // Check if we have required resources
490
+ if (taskType === "external-api" && !isExternalServiceAvailable()) {
491
+ // Return timeout to retry in 1 minute
492
+ return { timeoutSeconds: 60 };
493
+ }
494
+
495
+ // Process the message normally
496
+ console.log(`Processing ${taskType} task`);
497
+ await processTask(taskType, data);
498
+ // Message will be automatically deleted on successful completion
499
+ });
500
+ } catch (error) {
501
+ console.error("Worker processing error:", error);
502
+ }
503
+
504
+ // Example with exponential backoff
505
+ const backoffController = new AbortController();
506
+
507
+ try {
508
+ await worker.subscribe(backoffController.signal, async (message) => {
509
+ const maxRetries = 3;
510
+ const deliveryCount = message.deliveryCount;
511
+
512
+ try {
513
+ await processMessage(message.payload);
514
+ // Successful processing - message will be deleted
515
+ } catch (error) {
516
+ if (deliveryCount < maxRetries) {
517
+ // Exponential backoff: 2^deliveryCount minutes
518
+ const timeoutSeconds = Math.pow(2, deliveryCount) * 60;
519
+ console.log(
520
+ `Retrying message in ${timeoutSeconds} seconds (attempt ${deliveryCount})`,
521
+ );
522
+ return { timeoutSeconds: timeoutSeconds };
523
+ } else {
524
+ // Max retries reached, let the message fail and be deleted
525
+ console.error("Max retries reached, message will be discarded:", error);
526
+ throw error;
527
+ }
528
+ }
529
+ });
530
+ } catch (error) {
531
+ console.error("Backoff processing error:", error);
532
+ }
533
+ ```
534
+
535
+ ## License
536
+
537
+ MIT
538
+
539
+ ## Error Handling
540
+
541
+ The VQS client provides specific error types for different failure scenarios:
542
+
543
+ ### Error Types
544
+
545
+ - **`QueueEmptyError`**: Thrown when attempting to receive messages from an empty queue (204 status)
546
+
547
+ - Only thrown when directly using `client.receiveMessages()`
548
+ - `ConsumerGroup.subscribe()` handles this error internally and continues polling
549
+
550
+ - **`MessageLockedError`**: Thrown when a message is temporarily locked (423 status)
551
+
552
+ - Contains optional `retryAfter` property with seconds to wait before retry
553
+ - For `receiveMessages()` on FIFO queues: the next message in sequence is locked
554
+ - For `receiveMessageById()`: the requested message is locked
555
+ - `ConsumerGroup.subscribe()` handles this error internally when polling
556
+
557
+ - **`MessageNotFoundError`**: Message doesn't exist (404 status)
558
+
559
+ - **`MessageNotAvailableError`**: Message exists but isn't available for processing (409 status)
560
+
561
+ - **`FifoOrderingViolationError`**: FIFO queue ordering violation (409 status with nextMessageId)
562
+
563
+ - Contains `nextMessageId` property indicating which message to process first
564
+
565
+ - **`FailedDependencyError`**: FIFO ordering violation when receiving by ID (424 status)
566
+
567
+ - Contains `nextMessageId` property indicating which message must be processed first
568
+ - Similar to `FifoOrderingViolationError` but specifically for receive-by-ID operations
569
+
570
+ - **`MessageCorruptedError`**: Message data is corrupted or can't be parsed
571
+
572
+ - **`BadRequestError`**: Invalid request parameters (400 status)
573
+
574
+ - Invalid queue names, FIFO limit violations, missing required parameters
575
+
576
+ - **`UnauthorizedError`**: Authentication failure (401 status)
577
+
578
+ - Missing or invalid authentication token
579
+
580
+ - **`ForbiddenError`**: Access denied (403 status)
581
+
582
+ - Queue environment doesn't match token environment
583
+
584
+ - **`InternalServerError`**: Server-side errors (500+ status codes)
585
+ - Unexpected server errors, service unavailable, etc.
586
+
587
+ ### Error Handling Examples
588
+
589
+ ```typescript
590
+ import {
591
+ QueueEmptyError,
592
+ MessageLockedError,
593
+ FifoOrderingViolationError,
594
+ FailedDependencyError,
595
+ BadRequestError,
596
+ UnauthorizedError,
597
+ ForbiddenError,
598
+ InternalServerError,
599
+ } from "@vercel/queue";
600
+
601
+ // Handle empty queue or locked messages
602
+ try {
603
+ for await (const message of client.receiveMessages(options, transport)) {
604
+ // Process messages
605
+ }
606
+ } catch (error) {
607
+ if (error instanceof QueueEmptyError) {
608
+ console.log("Queue is empty, retry later");
609
+ } else if (error instanceof MessageLockedError) {
610
+ console.log("Next message in FIFO queue is locked");
611
+ if (error.retryAfter) {
612
+ console.log(`Retry after ${error.retryAfter} seconds`);
613
+ }
614
+ }
615
+ }
616
+
617
+ // Handle locked message with retry
618
+ try {
619
+ await consumer.receiveMessage(messageId, handler);
620
+ } catch (error) {
621
+ if (error instanceof MessageLockedError) {
622
+ console.log("Message is locked by another consumer");
623
+ if (error.retryAfter) {
624
+ console.log(`Retry after ${error.retryAfter} seconds`);
625
+ setTimeout(() => retry(), error.retryAfter * 1000);
626
+ }
627
+ } else if (error instanceof FailedDependencyError) {
628
+ // FIFO ordering violation for receive by ID
629
+ console.log(`Must process ${error.nextMessageId} first`);
630
+ }
631
+ }
632
+
633
+ // Handle authentication and authorization errors
634
+ try {
635
+ await topic.publish(payload);
636
+ } catch (error) {
637
+ if (error instanceof UnauthorizedError) {
638
+ console.log("Invalid token - refresh authentication");
639
+ } else if (error instanceof ForbiddenError) {
640
+ console.log("Environment mismatch - check token/queue configuration");
641
+ } else if (error instanceof BadRequestError) {
642
+ console.log("Invalid parameters:", error.message);
643
+ } else if (error instanceof InternalServerError) {
644
+ console.log("Server error - retry with backoff");
645
+ }
646
+ }
647
+
648
+ // Complete error handling pattern
649
+ function handleVQSError(error: unknown): void {
650
+ if (error instanceof QueueEmptyError || error instanceof MessageLockedError) {
651
+ // Transient errors - safe to retry
652
+ console.log("Temporary condition, will retry");
653
+ } else if (
654
+ error instanceof UnauthorizedError ||
655
+ error instanceof ForbiddenError
656
+ ) {
657
+ // Authentication/authorization errors - need to fix configuration
658
+ console.log("Auth error - check credentials");
659
+ } else if (error instanceof BadRequestError) {
660
+ // Client error - fix the request
661
+ console.log("Invalid request:", error.message);
662
+ } else if (error instanceof InternalServerError) {
663
+ // Server error - implement exponential backoff
664
+ console.log("Server error - retry with backoff");
665
+ } else {
666
+ // Unknown error
667
+ console.error("Unexpected error:", error);
668
+ }
669
+ }
670
+ ```