@vercel/queue 0.0.0-alpha.8 → 0.0.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/dist/index.mjs CHANGED
@@ -1,25 +1,40 @@
1
1
  // src/transports.ts
2
+ async function streamToBuffer(stream) {
3
+ let totalLength = 0;
4
+ const reader = stream.getReader();
5
+ const chunks = [];
6
+ try {
7
+ while (true) {
8
+ const { done, value } = await reader.read();
9
+ if (done) break;
10
+ chunks.push(value);
11
+ totalLength += value.length;
12
+ }
13
+ } finally {
14
+ reader.releaseLock();
15
+ }
16
+ return Buffer.concat(chunks, totalLength);
17
+ }
2
18
  var JsonTransport = class {
3
19
  contentType = "application/json";
20
+ replacer;
21
+ reviver;
22
+ /**
23
+ * Create a new JsonTransport.
24
+ * @param options - Optional JSON serialization options
25
+ * @param options.replacer - Custom replacer for JSON.stringify
26
+ * @param options.reviver - Custom reviver for JSON.parse
27
+ */
28
+ constructor(options = {}) {
29
+ this.replacer = options.replacer;
30
+ this.reviver = options.reviver;
31
+ }
4
32
  serialize(value) {
5
- return Buffer.from(JSON.stringify(value), "utf8");
33
+ return Buffer.from(JSON.stringify(value, this.replacer), "utf8");
6
34
  }
7
35
  async deserialize(stream) {
8
- const reader = stream.getReader();
9
- let totalLength = 0;
10
- const chunks = [];
11
- try {
12
- while (true) {
13
- const { done, value } = await reader.read();
14
- if (done) break;
15
- chunks.push(value);
16
- totalLength += value.length;
17
- }
18
- } finally {
19
- reader.releaseLock();
20
- }
21
- const buffer = Buffer.concat(chunks, totalLength);
22
- return JSON.parse(buffer.toString("utf8"));
36
+ const buffer = await streamToBuffer(stream);
37
+ return JSON.parse(buffer.toString("utf8"), this.reviver);
23
38
  }
24
39
  };
25
40
  var BufferTransport = class {
@@ -28,25 +43,7 @@ var BufferTransport = class {
28
43
  return value;
29
44
  }
30
45
  async deserialize(stream) {
31
- const reader = stream.getReader();
32
- const chunks = [];
33
- try {
34
- while (true) {
35
- const { done, value } = await reader.read();
36
- if (done) break;
37
- chunks.push(value);
38
- }
39
- } finally {
40
- reader.releaseLock();
41
- }
42
- const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
43
- const buffer = new Uint8Array(totalLength);
44
- let offset = 0;
45
- for (const chunk of chunks) {
46
- buffer.set(chunk, offset);
47
- offset += chunk.length;
48
- }
49
- return Buffer.from(buffer);
46
+ return await streamToBuffer(stream);
50
47
  }
51
48
  };
52
49
  var StreamTransport = class {
@@ -57,6 +54,10 @@ var StreamTransport = class {
57
54
  async deserialize(stream) {
58
55
  return stream;
59
56
  }
57
+ /**
58
+ * Consume any remaining stream data to prevent resource leaks.
59
+ * Called automatically by ConsumerGroup; manual call required for direct client usage.
60
+ */
60
61
  async finalize(payload) {
61
62
  const reader = payload.getReader();
62
63
  try {
@@ -70,9 +71,13 @@ var StreamTransport = class {
70
71
  }
71
72
  };
72
73
 
73
- // src/client.ts
74
+ // src/api-client.ts
74
75
  import { parseMultipartStream } from "mixpart";
75
76
 
77
+ // src/dev.ts
78
+ import * as fs from "fs";
79
+ import * as path from "path";
80
+
76
81
  // src/types.ts
77
82
  var MessageNotFoundError = class extends Error {
78
83
  constructor(messageId) {
@@ -103,6 +108,7 @@ var QueueEmptyError = class extends Error {
103
108
  }
104
109
  };
105
110
  var MessageLockedError = class extends Error {
111
+ /** Suggested retry delay in seconds, if provided by the server. */
106
112
  retryAfter;
107
113
  constructor(messageId, retryAfter) {
108
114
  const retryMessage = retryAfter ? ` Retry after ${retryAfter} seconds.` : " Try again later.";
@@ -141,882 +147,1497 @@ var InvalidLimitError = class extends Error {
141
147
  this.name = "InvalidLimitError";
142
148
  }
143
149
  };
144
-
145
- // src/client.ts
146
- async function consumeStream(stream) {
147
- const reader = stream.getReader();
148
- try {
149
- while (true) {
150
- const { done } = await reader.read();
151
- if (done) break;
152
- }
153
- } finally {
154
- reader.releaseLock();
150
+ var MessageAlreadyProcessedError = class extends Error {
151
+ constructor(messageId) {
152
+ super(`Message ${messageId} has already been processed`);
153
+ this.name = "MessageAlreadyProcessedError";
155
154
  }
156
- }
157
- function parseQueueHeaders(headers) {
158
- const messageId = headers.get("Vqs-Message-Id");
159
- const deliveryCountStr = headers.get("Vqs-Delivery-Count") || "0";
160
- const timestamp = headers.get("Vqs-Timestamp");
161
- const contentType = headers.get("Content-Type") || "application/octet-stream";
162
- const ticket = headers.get("Vqs-Ticket");
163
- if (!messageId || !timestamp || !ticket) {
164
- return null;
155
+ };
156
+ var DuplicateMessageError = class extends Error {
157
+ idempotencyKey;
158
+ constructor(message, idempotencyKey) {
159
+ super(message);
160
+ this.name = "DuplicateMessageError";
161
+ this.idempotencyKey = idempotencyKey;
165
162
  }
166
- const deliveryCount = parseInt(deliveryCountStr, 10);
167
- if (isNaN(deliveryCount)) {
168
- return null;
163
+ };
164
+ var ConsumerDiscoveryError = class extends Error {
165
+ deploymentId;
166
+ constructor(message, deploymentId) {
167
+ super(message);
168
+ this.name = "ConsumerDiscoveryError";
169
+ this.deploymentId = deploymentId;
169
170
  }
170
- return {
171
- messageId,
172
- deliveryCount,
173
- timestamp,
174
- contentType,
175
- ticket
176
- };
171
+ };
172
+ var ConsumerRegistryNotConfiguredError = class extends Error {
173
+ constructor(message = "Consumer registry not configured") {
174
+ super(message);
175
+ this.name = "ConsumerRegistryNotConfiguredError";
176
+ }
177
+ };
178
+
179
+ // src/consumer-group.ts
180
+ var DEFAULT_VISIBILITY_TIMEOUT_SECONDS = 300;
181
+ var MIN_VISIBILITY_TIMEOUT_SECONDS = 30;
182
+ var MAX_RENEWAL_INTERVAL_SECONDS = 60;
183
+ var MIN_RENEWAL_INTERVAL_SECONDS = 10;
184
+ var RETRY_INTERVAL_MS = 3e3;
185
+ function calculateRenewalInterval(visibilityTimeoutSeconds) {
186
+ return Math.min(
187
+ MAX_RENEWAL_INTERVAL_SECONDS,
188
+ Math.max(MIN_RENEWAL_INTERVAL_SECONDS, visibilityTimeoutSeconds / 5)
189
+ );
177
190
  }
178
- var QueueClient = class _QueueClient {
179
- baseUrl;
180
- token;
191
+ var ConsumerGroup = class {
192
+ client;
193
+ topicName;
194
+ consumerGroupName;
195
+ visibilityTimeout;
181
196
  /**
182
- * Internal default instance for use by convenience functions
183
- * @internal
197
+ * Create a new ConsumerGroup instance.
198
+ *
199
+ * @param client - ApiClient instance to use for API calls (transport is configured on the client)
200
+ * @param topicName - Name of the topic to consume from (pattern: `[A-Za-z0-9_-]+`)
201
+ * @param consumerGroupName - Name of the consumer group (pattern: `[A-Za-z0-9_-]+`)
202
+ * @param options - Optional configuration
203
+ * @param options.visibilityTimeoutSeconds - Message lock duration (default: 300, max: 3600)
184
204
  */
185
- static _defaultInstance = null;
205
+ constructor(client, topicName, consumerGroupName, options = {}) {
206
+ this.client = client;
207
+ this.topicName = topicName;
208
+ this.consumerGroupName = consumerGroupName;
209
+ this.visibilityTimeout = Math.max(
210
+ MIN_VISIBILITY_TIMEOUT_SECONDS,
211
+ options.visibilityTimeoutSeconds ?? DEFAULT_VISIBILITY_TIMEOUT_SECONDS
212
+ );
213
+ }
186
214
  /**
187
- * Create a new Vercel Queue Service client
188
- * @param options Client configuration options (optional - will auto-detect Vercel Function environment)
215
+ * Check if an error is a 4xx client error that should stop retries.
216
+ * 4xx errors indicate the request is fundamentally invalid and retrying won't help.
217
+ * - 409: Ticket mismatch (lost ownership to another consumer)
218
+ * - 404: Message/receipt handle not found
219
+ * - 400, 401, 403: Other client errors
189
220
  */
190
- constructor(options = {}) {
191
- this.baseUrl = options.baseUrl || "https://vqs.vercel.sh";
192
- if (options.token) {
193
- this.token = options.token;
194
- } else {
195
- const token = this.getVercelOidcTokenSync();
196
- if (!token) {
197
- throw new Error(
198
- "Failed to get OIDC token from Vercel Functions. Make sure you are running in a Vercel Function environment, or provide a token explicitly.\n\nTo set up your environment:\n1. Link your project: 'vercel link'\n2. Pull environment variables: 'vercel env pull'\n3. Run with environment: 'dotenv -e .env.local -- your-command'"
199
- );
200
- }
201
- this.token = token;
202
- }
221
+ isClientError(error) {
222
+ return error instanceof MessageNotAvailableError || // 409 - ticket mismatch, lost ownership
223
+ error instanceof MessageNotFoundError || // 404 - receipt handle not found
224
+ error instanceof BadRequestError || // 400 - invalid parameters
225
+ error instanceof UnauthorizedError || // 401 - auth failed
226
+ error instanceof ForbiddenError;
203
227
  }
204
228
  /**
205
- * Get the default client instance for internal use by convenience functions
206
- * @internal
229
+ * Starts a background loop that periodically extends the visibility timeout for a message.
230
+ *
231
+ * Timing strategy:
232
+ * - Renewal interval: min(60s, max(10s, visibilityTimeout/5))
233
+ * - Extensions request the same duration as the initial visibility timeout
234
+ * - When `visibilityDeadline` is provided (binary mode small body), the first
235
+ * extension delay is calculated from the time remaining until the deadline
236
+ * using the same renewal formula, ensuring the first extension fires before
237
+ * the server-assigned lease expires. Subsequent renewals use the standard interval.
238
+ *
239
+ * Retry strategy:
240
+ * - On transient failures (5xx, network errors): retry every 3 seconds
241
+ * - On 4xx client errors: stop retrying (the lease is lost or invalid)
242
+ *
243
+ * @param receiptHandle - The receipt handle to extend visibility for
244
+ * @param options - Optional configuration
245
+ * @param options.visibilityDeadline - Absolute deadline (from server's `ce-vqsvisibilitydeadline`)
246
+ * when the current visibility timeout expires. Used to calculate the first extension delay.
207
247
  */
208
- static _getDefaultInstance() {
209
- if (!this._defaultInstance) {
210
- this._defaultInstance = new _QueueClient();
248
+ startVisibilityExtension(receiptHandle, options) {
249
+ let isRunning = true;
250
+ let isResolved = false;
251
+ let resolveLifecycle;
252
+ let timeoutId = null;
253
+ const renewalIntervalMs = calculateRenewalInterval(this.visibilityTimeout) * 1e3;
254
+ let firstDelayMs = renewalIntervalMs;
255
+ if (options?.visibilityDeadline) {
256
+ const timeRemainingMs = options.visibilityDeadline.getTime() - Date.now();
257
+ if (timeRemainingMs > 0) {
258
+ const timeRemainingSeconds = timeRemainingMs / 1e3;
259
+ firstDelayMs = calculateRenewalInterval(timeRemainingSeconds) * 1e3;
260
+ } else {
261
+ firstDelayMs = 0;
262
+ }
211
263
  }
212
- return this._defaultInstance;
264
+ const lifecyclePromise = new Promise((resolve) => {
265
+ resolveLifecycle = resolve;
266
+ });
267
+ const safeResolve = () => {
268
+ if (!isResolved) {
269
+ isResolved = true;
270
+ resolveLifecycle();
271
+ }
272
+ };
273
+ const extend = async () => {
274
+ if (!isRunning) {
275
+ safeResolve();
276
+ return;
277
+ }
278
+ try {
279
+ await this.client.changeVisibility({
280
+ queueName: this.topicName,
281
+ consumerGroup: this.consumerGroupName,
282
+ receiptHandle,
283
+ visibilityTimeoutSeconds: this.visibilityTimeout
284
+ });
285
+ if (isRunning) {
286
+ timeoutId = setTimeout(() => extend(), renewalIntervalMs);
287
+ } else {
288
+ safeResolve();
289
+ }
290
+ } catch (error) {
291
+ if (this.isClientError(error)) {
292
+ console.error(
293
+ `Visibility extension failed with client error for receipt handle ${receiptHandle} (stopping retries):`,
294
+ error
295
+ );
296
+ safeResolve();
297
+ return;
298
+ }
299
+ console.error(
300
+ `Failed to extend visibility for receipt handle ${receiptHandle} (will retry in ${RETRY_INTERVAL_MS / 1e3}s):`,
301
+ error
302
+ );
303
+ if (isRunning) {
304
+ timeoutId = setTimeout(() => extend(), RETRY_INTERVAL_MS);
305
+ } else {
306
+ safeResolve();
307
+ }
308
+ }
309
+ };
310
+ timeoutId = setTimeout(() => extend(), firstDelayMs);
311
+ return async (waitForCompletion = false) => {
312
+ isRunning = false;
313
+ if (timeoutId) {
314
+ clearTimeout(timeoutId);
315
+ timeoutId = null;
316
+ }
317
+ if (waitForCompletion) {
318
+ await lifecyclePromise;
319
+ } else {
320
+ safeResolve();
321
+ }
322
+ };
213
323
  }
214
324
  /**
215
- * Synchronously get OIDC token from environment
216
- * Used internally by constructor - mirrors the logic from getVercelOidcToken but synchronously
325
+ * Clean up the message payload if the transport supports it and payload exists.
217
326
  */
218
- getVercelOidcTokenSync() {
327
+ async finalizePayload(payload) {
328
+ const transport = this.client.getTransport();
329
+ if (transport.finalize && payload !== void 0 && payload !== null) {
330
+ try {
331
+ await transport.finalize(payload);
332
+ } catch (finalizeError) {
333
+ console.warn("Failed to finalize message payload:", finalizeError);
334
+ }
335
+ }
336
+ }
337
+ async processMessage(message, handler, options) {
338
+ const stopExtension = this.startVisibilityExtension(
339
+ message.receiptHandle,
340
+ options
341
+ );
342
+ const metadata = {
343
+ messageId: message.messageId,
344
+ deliveryCount: message.deliveryCount,
345
+ createdAt: message.createdAt,
346
+ expiresAt: message.expiresAt,
347
+ topicName: this.topicName,
348
+ consumerGroup: this.consumerGroupName,
349
+ region: this.client.getRegion()
350
+ };
219
351
  try {
220
- const SYMBOL_FOR_REQ_CONTEXT = Symbol.for("@vercel/request-context");
221
- const fromSymbol = globalThis;
222
- const context = fromSymbol[SYMBOL_FOR_REQ_CONTEXT]?.get?.() ?? {};
223
- const token = context.headers?.["x-vercel-oidc-token"] ?? process.env.VERCEL_OIDC_TOKEN;
224
- return token || null;
225
- } catch {
226
- return null;
352
+ await handler(message.payload, metadata);
353
+ await stopExtension();
354
+ await this.client.acknowledgeMessage({
355
+ queueName: this.topicName,
356
+ consumerGroup: this.consumerGroupName,
357
+ receiptHandle: message.receiptHandle
358
+ });
359
+ } catch (error) {
360
+ await stopExtension();
361
+ if (options?.retry) {
362
+ let directive;
363
+ try {
364
+ directive = options.retry(error, metadata);
365
+ } catch (retryError) {
366
+ console.warn("retry handler threw:", retryError);
367
+ }
368
+ if (directive) {
369
+ if ("acknowledge" in directive && directive.acknowledge) {
370
+ try {
371
+ await this.client.acknowledgeMessage({
372
+ queueName: this.topicName,
373
+ consumerGroup: this.consumerGroupName,
374
+ receiptHandle: message.receiptHandle
375
+ });
376
+ } catch (ackError) {
377
+ console.warn("Failed to acknowledge message:", ackError);
378
+ }
379
+ await this.finalizePayload(message.payload);
380
+ return;
381
+ }
382
+ if ("afterSeconds" in directive && typeof directive.afterSeconds === "number") {
383
+ try {
384
+ await this.client.changeVisibility({
385
+ queueName: this.topicName,
386
+ consumerGroup: this.consumerGroupName,
387
+ receiptHandle: message.receiptHandle,
388
+ visibilityTimeoutSeconds: directive.afterSeconds
389
+ });
390
+ } catch (changeError) {
391
+ console.warn(
392
+ "Failed to reschedule message for retry:",
393
+ changeError
394
+ );
395
+ }
396
+ await this.finalizePayload(message.payload);
397
+ return;
398
+ }
399
+ }
400
+ }
401
+ await this.finalizePayload(message.payload);
402
+ throw error;
227
403
  }
228
404
  }
229
405
  /**
230
- * Send a message to a queue
231
- * @param options Send message options
232
- * @param transport Serializer/deserializer for the payload
233
- * @returns Promise with the message ID
234
- * @throws {BadRequestError} When request parameters are invalid
235
- * @throws {UnauthorizedError} When authentication fails
236
- * @throws {ForbiddenError} When access is denied (environment mismatch)
237
- * @throws {InternalServerError} When server encounters an error
406
+ * Process a pre-fetched message directly, without calling `receiveMessageById`.
407
+ *
408
+ * Used by the binary mode (v2beta) small body fast path, where the server
409
+ * pushes the full message payload in the callback request. The message is
410
+ * processed with the same lifecycle guarantees as `consume()`:
411
+ * - Visibility timeout is extended periodically during processing
412
+ * - Message is acknowledged on successful handler completion
413
+ * - Payload is finalized on error if the transport supports it
414
+ *
415
+ * @param handler - Function to process the message payload and metadata
416
+ * @param message - The complete message including payload and receipt handle
417
+ * @param options - Optional configuration
418
+ * @param options.visibilityDeadline - Absolute deadline when the server-assigned
419
+ * visibility timeout expires (from `ce-vqsvisibilitydeadline`). Used to
420
+ * schedule the first visibility extension before the lease expires.
238
421
  */
239
- async sendMessage(options, transport) {
240
- const { queueName, payload, idempotencyKey, retentionSeconds } = options;
241
- const headers = new Headers({
242
- Authorization: `Bearer ${this.token}`,
243
- "Vqs-Queue-Name": queueName,
244
- "Content-Type": transport.contentType
245
- });
246
- if (process.env.VERCEL_DEPLOYMENT_ID) {
247
- headers.set("Vqs-Deployment-Id", process.env.VERCEL_DEPLOYMENT_ID);
248
- }
249
- if (idempotencyKey) {
250
- headers.set("Vqs-Idempotency-Key", idempotencyKey);
251
- }
252
- if (retentionSeconds !== void 0) {
253
- headers.set("Vqs-Retention-Seconds", retentionSeconds.toString());
254
- }
255
- const body = transport.serialize(payload);
256
- const response = await fetch(`${this.baseUrl}/api/v2/messages`, {
257
- method: "POST",
258
- headers,
259
- body
260
- });
261
- if (!response.ok) {
262
- if (response.status === 400) {
263
- const errorText = await response.text();
264
- throw new BadRequestError(errorText || "Invalid parameters");
265
- }
266
- if (response.status === 401) {
267
- throw new UnauthorizedError();
268
- }
269
- if (response.status === 403) {
270
- throw new ForbiddenError();
271
- }
272
- if (response.status === 409) {
273
- throw new Error("Duplicate idempotency key detected");
274
- }
275
- if (response.status >= 500) {
276
- throw new InternalServerError(
277
- `Server error: ${response.status} ${response.statusText}`
278
- );
422
+ async consumeMessage(handler, message, options) {
423
+ await this.processMessage(message, handler, options);
424
+ }
425
+ async consume(handler, options) {
426
+ const retry = options?.retry;
427
+ if (options && "messageId" in options) {
428
+ const response = await this.client.receiveMessageById({
429
+ queueName: this.topicName,
430
+ consumerGroup: this.consumerGroupName,
431
+ messageId: options.messageId,
432
+ visibilityTimeoutSeconds: this.visibilityTimeout
433
+ });
434
+ await this.processMessage(response.message, handler, { retry });
435
+ return 1;
436
+ } else {
437
+ const limit = options && "limit" in options ? options.limit : 1;
438
+ let messagesProcessed = 0;
439
+ for await (const message of this.client.receiveMessages({
440
+ queueName: this.topicName,
441
+ consumerGroup: this.consumerGroupName,
442
+ visibilityTimeoutSeconds: this.visibilityTimeout,
443
+ limit
444
+ })) {
445
+ messagesProcessed++;
446
+ await this.processMessage(message, handler, { retry });
279
447
  }
280
- throw new Error(
281
- `Failed to send message: ${response.status} ${response.statusText}`
282
- );
448
+ return messagesProcessed;
283
449
  }
284
- const responseData = await response.json();
285
- return responseData;
286
450
  }
287
451
  /**
288
- * Receive messages from a queue
289
- * @param options Receive messages options
290
- * @param transport Serializer/deserializer for the payload
291
- * @returns AsyncGenerator that yields messages as they arrive
292
- * @throws {InvalidLimitError} When limit parameter is not between 1 and 10
293
- * @throws {QueueEmptyError} When no messages are available (204)
294
- * @throws {MessageLockedError} When messages are temporarily locked (423)
452
+ * Get the consumer group name
453
+ */
454
+ get name() {
455
+ return this.consumerGroupName;
456
+ }
457
+ /**
458
+ * Get the topic name this consumer group is subscribed to
459
+ */
460
+ get topic() {
461
+ return this.topicName;
462
+ }
463
+ };
464
+
465
+ // src/topic.ts
466
+ var Topic = class {
467
+ client;
468
+ topicName;
469
+ /**
470
+ * @param client ApiClient instance to use for API calls
471
+ * @param topicName Name of the topic to work with
472
+ */
473
+ constructor(client, topicName) {
474
+ this.client = client;
475
+ this.topicName = topicName;
476
+ }
477
+ /**
478
+ * Publish a message to the topic
479
+ * @param payload The data to publish
480
+ * @param options Optional publish options
481
+ * @returns `{ messageId }` — `messageId` is `null` when deferred
295
482
  * @throws {BadRequestError} When request parameters are invalid
296
483
  * @throws {UnauthorizedError} When authentication fails
297
484
  * @throws {ForbiddenError} When access is denied (environment mismatch)
298
485
  * @throws {InternalServerError} When server encounters an error
299
486
  */
300
- async *receiveMessages(options, transport) {
301
- const { queueName, consumerGroup, visibilityTimeoutSeconds, limit } = options;
302
- if (limit !== void 0 && (limit < 1 || limit > 10)) {
303
- throw new InvalidLimitError(limit);
304
- }
305
- const headers = new Headers({
306
- Authorization: `Bearer ${this.token}`,
307
- "Vqs-Queue-Name": queueName,
308
- "Vqs-Consumer-Group": consumerGroup,
309
- Accept: "multipart/mixed"
310
- });
311
- if (visibilityTimeoutSeconds !== void 0) {
312
- headers.set(
313
- "Vqs-Visibility-Timeout",
314
- visibilityTimeoutSeconds.toString()
315
- );
316
- }
317
- if (limit !== void 0) {
318
- headers.set("Vqs-Limit", limit.toString());
319
- }
320
- const response = await fetch(`${this.baseUrl}/api/v2/messages`, {
321
- method: "GET",
322
- headers
487
+ async publish(payload, options) {
488
+ const result = await this.client.sendMessage({
489
+ queueName: this.topicName,
490
+ payload,
491
+ idempotencyKey: options?.idempotencyKey,
492
+ retentionSeconds: options?.retentionSeconds,
493
+ delaySeconds: options?.delaySeconds,
494
+ headers: options?.headers
323
495
  });
324
- if (response.status === 204) {
325
- throw new QueueEmptyError(queueName, consumerGroup);
326
- }
327
- if (!response.ok) {
328
- if (response.status === 400) {
329
- const errorText = await response.text();
330
- throw new BadRequestError(errorText || "Invalid parameters");
331
- }
332
- if (response.status === 401) {
333
- throw new UnauthorizedError();
334
- }
335
- if (response.status === 403) {
336
- throw new ForbiddenError();
337
- }
338
- if (response.status === 423) {
339
- const retryAfterHeader = response.headers.get("Retry-After");
340
- let retryAfter;
341
- if (retryAfterHeader) {
342
- const parsed = parseInt(retryAfterHeader, 10);
343
- retryAfter = isNaN(parsed) ? void 0 : parsed;
344
- }
345
- throw new MessageLockedError("next message", retryAfter);
346
- }
347
- if (response.status >= 500) {
348
- throw new InternalServerError(
349
- `Server error: ${response.status} ${response.statusText}`
350
- );
351
- }
352
- throw new Error(
353
- `Failed to receive messages: ${response.status} ${response.statusText}`
496
+ if (result.messageId && isDevMode()) {
497
+ triggerDevCallbacks(
498
+ this.topicName,
499
+ result.messageId,
500
+ this.client.getRegion()
354
501
  );
355
502
  }
356
- for await (const multipartMessage of parseMultipartStream(response)) {
357
- try {
358
- const parsedHeaders = parseQueueHeaders(multipartMessage.headers);
359
- if (!parsedHeaders) {
360
- console.warn("Missing required queue headers in multipart part");
361
- await consumeStream(multipartMessage.payload);
362
- continue;
363
- }
364
- const deserializedPayload = await transport.deserialize(
365
- multipartMessage.payload
366
- );
367
- const message = {
368
- ...parsedHeaders,
369
- payload: deserializedPayload
370
- };
371
- yield message;
503
+ return { messageId: result.messageId };
504
+ }
505
+ /**
506
+ * Create a consumer group for this topic
507
+ * @param consumerGroupName Name of the consumer group
508
+ * @param options Optional configuration for the consumer group
509
+ * @returns A ConsumerGroup instance
510
+ */
511
+ consumerGroup(consumerGroupName, options) {
512
+ return new ConsumerGroup(
513
+ this.client,
514
+ this.topicName,
515
+ consumerGroupName,
516
+ options
517
+ );
518
+ }
519
+ /**
520
+ * Get the topic name
521
+ */
522
+ get name() {
523
+ return this.topicName;
524
+ }
525
+ };
526
+
527
+ // src/callback.ts
528
+ var CLOUD_EVENT_TYPE_V1BETA = "com.vercel.queue.v1beta";
529
+ var CLOUD_EVENT_TYPE_V2BETA = "com.vercel.queue.v2beta";
530
+ function matchesWildcardPattern(topicName, pattern) {
531
+ const prefix = pattern.slice(0, -1);
532
+ return topicName.startsWith(prefix);
533
+ }
534
+ function isRecord(value) {
535
+ return typeof value === "object" && value !== null;
536
+ }
537
+ function parseV1StructuredBody(body, contentType) {
538
+ if (!contentType || !contentType.includes("application/cloudevents+json")) {
539
+ throw new Error(
540
+ "Invalid content type: expected 'application/cloudevents+json'"
541
+ );
542
+ }
543
+ if (!isRecord(body) || !body.type || !body.source || !body.id || !isRecord(body.data)) {
544
+ throw new Error("Invalid CloudEvent: missing required fields");
545
+ }
546
+ if (body.type !== CLOUD_EVENT_TYPE_V1BETA) {
547
+ throw new Error(
548
+ `Invalid CloudEvent type: expected '${CLOUD_EVENT_TYPE_V1BETA}', got '${String(body.type)}'`
549
+ );
550
+ }
551
+ const { data } = body;
552
+ const missingFields = [];
553
+ if (!("queueName" in data)) missingFields.push("queueName");
554
+ if (!("consumerGroup" in data)) missingFields.push("consumerGroup");
555
+ if (!("messageId" in data)) missingFields.push("messageId");
556
+ if (missingFields.length > 0) {
557
+ throw new Error(
558
+ `Missing required CloudEvent data fields: ${missingFields.join(", ")}`
559
+ );
560
+ }
561
+ return {
562
+ queueName: String(data.queueName),
563
+ consumerGroup: String(data.consumerGroup),
564
+ messageId: String(data.messageId)
565
+ };
566
+ }
567
+ function getHeader(headers, name) {
568
+ if (headers instanceof Headers) {
569
+ return headers.get(name);
570
+ }
571
+ const value = headers[name];
572
+ if (Array.isArray(value)) return value[0] ?? null;
573
+ return value ?? null;
574
+ }
575
+ function parseBinaryHeaders(headers) {
576
+ const ceType = getHeader(headers, "ce-type");
577
+ if (ceType !== CLOUD_EVENT_TYPE_V2BETA) {
578
+ throw new Error(
579
+ `Invalid CloudEvent type: expected '${CLOUD_EVENT_TYPE_V2BETA}', got '${ceType}'`
580
+ );
581
+ }
582
+ const queueName = getHeader(headers, "ce-vqsqueuename");
583
+ const consumerGroup = getHeader(headers, "ce-vqsconsumergroup");
584
+ const messageId = getHeader(headers, "ce-vqsmessageid");
585
+ const missingFields = [];
586
+ if (!queueName) missingFields.push("ce-vqsqueuename");
587
+ if (!consumerGroup) missingFields.push("ce-vqsconsumergroup");
588
+ if (!messageId) missingFields.push("ce-vqsmessageid");
589
+ if (missingFields.length > 0) {
590
+ throw new Error(
591
+ `Missing required CloudEvent headers: ${missingFields.join(", ")}`
592
+ );
593
+ }
594
+ const region = getHeader(headers, "ce-vqsregion") ?? void 0;
595
+ const base = {
596
+ queueName,
597
+ consumerGroup,
598
+ messageId,
599
+ region
600
+ };
601
+ const receiptHandle = getHeader(headers, "ce-vqsreceipthandle");
602
+ if (!receiptHandle) {
603
+ return base;
604
+ }
605
+ const result = { ...base, receiptHandle };
606
+ const deliveryCount = getHeader(headers, "ce-vqsdeliverycount");
607
+ if (deliveryCount) {
608
+ result.deliveryCount = parseInt(deliveryCount, 10);
609
+ }
610
+ const createdAt = getHeader(headers, "ce-vqscreatedat");
611
+ if (createdAt) {
612
+ result.createdAt = createdAt;
613
+ }
614
+ const expiresAt = getHeader(headers, "ce-vqsexpiresat");
615
+ if (expiresAt) {
616
+ result.expiresAt = expiresAt;
617
+ }
618
+ const contentType = getHeader(headers, "content-type");
619
+ if (contentType) {
620
+ result.contentType = contentType;
621
+ }
622
+ const visibilityDeadline = getHeader(headers, "ce-vqsvisibilitydeadline");
623
+ if (visibilityDeadline) {
624
+ result.visibilityDeadline = visibilityDeadline;
625
+ }
626
+ return result;
627
+ }
628
+ function parseRawCallback(body, headers) {
629
+ const ceType = getHeader(headers, "ce-type");
630
+ if (ceType === CLOUD_EVENT_TYPE_V2BETA) {
631
+ const result = parseBinaryHeaders(headers);
632
+ if ("receiptHandle" in result) {
633
+ result.parsedPayload = body;
634
+ }
635
+ return result;
636
+ }
637
+ return parseV1StructuredBody(body, getHeader(headers, "content-type"));
638
+ }
639
+ async function parseCallback(request) {
640
+ const ceType = request.headers.get("ce-type");
641
+ if (ceType === CLOUD_EVENT_TYPE_V2BETA) {
642
+ const result = parseBinaryHeaders(request.headers);
643
+ if ("receiptHandle" in result && request.body) {
644
+ result.rawBody = request.body;
645
+ }
646
+ return result;
647
+ }
648
+ let body;
649
+ try {
650
+ body = await request.json();
651
+ } catch {
652
+ throw new Error("Failed to parse CloudEvent from request body");
653
+ }
654
+ const headers = {};
655
+ request.headers.forEach((value, key) => {
656
+ headers[key] = value;
657
+ });
658
+ return parseRawCallback(body, headers);
659
+ }
660
+ async function handleCallback(handler, request, options) {
661
+ const { queueName, consumerGroup, messageId } = request;
662
+ if (!options?.client) {
663
+ throw new Error("HandleCallbackOptions.client is required");
664
+ }
665
+ let api = getApiClient(options.client);
666
+ if (request.region) {
667
+ api = api.withRegion(request.region);
668
+ }
669
+ const topic = new Topic(api, queueName);
670
+ const cg = topic.consumerGroup(
671
+ consumerGroup,
672
+ options?.visibilityTimeoutSeconds !== void 0 ? { visibilityTimeoutSeconds: options.visibilityTimeoutSeconds } : void 0
673
+ );
674
+ if ("receiptHandle" in request) {
675
+ const transport = api.getTransport();
676
+ let payload;
677
+ if (request.rawBody) {
678
+ payload = await transport.deserialize(request.rawBody);
679
+ } else if (request.parsedPayload !== void 0) {
680
+ payload = request.parsedPayload;
681
+ } else {
682
+ throw new Error(
683
+ "Binary mode callback with receipt handle is missing payload"
684
+ );
685
+ }
686
+ const message = {
687
+ messageId,
688
+ payload,
689
+ deliveryCount: request.deliveryCount ?? 1,
690
+ createdAt: request.createdAt ? new Date(request.createdAt) : /* @__PURE__ */ new Date(),
691
+ expiresAt: request.expiresAt ? new Date(request.expiresAt) : void 0,
692
+ contentType: request.contentType ?? transport.contentType,
693
+ receiptHandle: request.receiptHandle
694
+ };
695
+ const visibilityDeadline = request.visibilityDeadline ? new Date(request.visibilityDeadline) : void 0;
696
+ await cg.consumeMessage(handler, message, {
697
+ visibilityDeadline,
698
+ retry: options?.retry
699
+ });
700
+ } else {
701
+ await cg.consume(handler, { messageId, retry: options?.retry });
702
+ }
703
+ }
704
+
705
+ // src/dev.ts
706
+ var ROUTE_MAPPINGS_KEY = Symbol.for("@vercel/queue.devRouteMappings");
707
+ function filePathToUrlPath(filePath) {
708
+ let urlPath = filePath.replace(/^app\//, "/").replace(/^pages\//, "/").replace(/\/route\.(ts|mts|js|mjs|tsx|jsx)$/, "").replace(/\.(ts|mts|js|mjs|tsx|jsx)$/, "");
709
+ if (!urlPath.startsWith("/")) {
710
+ urlPath = "/" + urlPath;
711
+ }
712
+ return urlPath;
713
+ }
714
+ function filePathToConsumerGroup(filePath) {
715
+ return filePath.replace(/_/g, "__").replace(/\//g, "_S").replace(/\./g, "_D");
716
+ }
717
+ function getDevRouteMappings() {
718
+ const g = globalThis;
719
+ if (ROUTE_MAPPINGS_KEY in g) {
720
+ return g[ROUTE_MAPPINGS_KEY] ?? null;
721
+ }
722
+ try {
723
+ const vercelJsonPath = path.join(process.cwd(), "vercel.json");
724
+ if (!fs.existsSync(vercelJsonPath)) {
725
+ g[ROUTE_MAPPINGS_KEY] = null;
726
+ return null;
727
+ }
728
+ const vercelJson = JSON.parse(fs.readFileSync(vercelJsonPath, "utf-8"));
729
+ if (!vercelJson.functions) {
730
+ g[ROUTE_MAPPINGS_KEY] = null;
731
+ return null;
732
+ }
733
+ const mappings = [];
734
+ for (const [filePath, config] of Object.entries(vercelJson.functions)) {
735
+ if (!config.experimentalTriggers) continue;
736
+ for (const trigger of config.experimentalTriggers) {
737
+ if (trigger.type?.startsWith("queue/") && trigger.topic) {
738
+ mappings.push({
739
+ urlPath: filePathToUrlPath(filePath),
740
+ topic: trigger.topic,
741
+ consumer: filePathToConsumerGroup(filePath)
742
+ });
743
+ }
744
+ }
745
+ }
746
+ g[ROUTE_MAPPINGS_KEY] = mappings.length > 0 ? mappings : null;
747
+ return g[ROUTE_MAPPINGS_KEY];
748
+ } catch (error) {
749
+ console.warn("[Dev Mode] Failed to read vercel.json:", error);
750
+ g[ROUTE_MAPPINGS_KEY] = null;
751
+ return null;
752
+ }
753
+ }
754
+ function findMatchingRoutes(topicName) {
755
+ const mappings = getDevRouteMappings();
756
+ if (!mappings) {
757
+ return [];
758
+ }
759
+ return mappings.filter((mapping) => {
760
+ if (mapping.topic.includes("*")) {
761
+ return matchesWildcardPattern(topicName, mapping.topic);
762
+ }
763
+ return mapping.topic === topicName;
764
+ });
765
+ }
766
+ function isDevMode() {
767
+ return process.env.NODE_ENV === "development";
768
+ }
769
+ var DEV_VISIBILITY_POLL_INTERVAL = 50;
770
+ var DEV_VISIBILITY_MAX_WAIT = 5e3;
771
+ var DEV_VISIBILITY_BACKOFF_MULTIPLIER = 2;
772
+ async function waitForMessageVisibility(topicName, consumerGroup, messageId, region) {
773
+ const client = new ApiClient({ region });
774
+ let elapsed = 0;
775
+ let interval = DEV_VISIBILITY_POLL_INTERVAL;
776
+ while (elapsed < DEV_VISIBILITY_MAX_WAIT) {
777
+ try {
778
+ await client.receiveMessageById({
779
+ queueName: topicName,
780
+ consumerGroup,
781
+ messageId,
782
+ visibilityTimeoutSeconds: 0
783
+ });
784
+ return true;
785
+ } catch (error) {
786
+ if (error instanceof MessageNotFoundError) {
787
+ await new Promise((resolve) => setTimeout(resolve, interval));
788
+ elapsed += interval;
789
+ interval = Math.min(
790
+ interval * DEV_VISIBILITY_BACKOFF_MULTIPLIER,
791
+ DEV_VISIBILITY_MAX_WAIT - elapsed
792
+ );
793
+ continue;
794
+ }
795
+ if (error instanceof MessageAlreadyProcessedError) {
796
+ console.log(
797
+ `[Dev Mode] Message already processed: topic="${topicName}" messageId="${messageId}"`
798
+ );
799
+ return false;
800
+ }
801
+ console.error(
802
+ `[Dev Mode] Error polling for message visibility: topic="${topicName}" messageId="${messageId}"`,
803
+ error
804
+ );
805
+ return false;
806
+ }
807
+ }
808
+ console.warn(
809
+ `[Dev Mode] Message visibility timeout after ${DEV_VISIBILITY_MAX_WAIT}ms: topic="${topicName}" messageId="${messageId}"`
810
+ );
811
+ return false;
812
+ }
813
+ function triggerDevCallbacks(topicName, messageId, region, delaySeconds) {
814
+ if (delaySeconds && delaySeconds > 0) {
815
+ console.log(
816
+ `[Dev Mode] Message sent with delay: topic="${topicName}" messageId="${messageId}" delay=${delaySeconds}s`
817
+ );
818
+ setTimeout(() => {
819
+ triggerDevCallbacks(topicName, messageId, region);
820
+ }, delaySeconds * 1e3);
821
+ return;
822
+ }
823
+ console.log(
824
+ `[Dev Mode] Message sent: topic="${topicName}" messageId="${messageId}"`
825
+ );
826
+ const matchingRoutes = findMatchingRoutes(topicName);
827
+ if (matchingRoutes.length === 0) {
828
+ console.log(
829
+ `[Dev Mode] No matching routes in vercel.json for topic "${topicName}"`
830
+ );
831
+ return;
832
+ }
833
+ const consumerGroups = matchingRoutes.map((r) => r.consumer);
834
+ console.log(
835
+ `[Dev Mode] Scheduling callbacks for topic="${topicName}" messageId="${messageId}" \u2192 consumers: [${consumerGroups.join(", ")}]`
836
+ );
837
+ (async () => {
838
+ const firstRoute = matchingRoutes[0];
839
+ const isVisible = await waitForMessageVisibility(
840
+ topicName,
841
+ firstRoute.consumer,
842
+ messageId,
843
+ region
844
+ );
845
+ if (!isVisible) {
846
+ console.warn(
847
+ `[Dev Mode] Skipping callbacks - message not visible: topic="${topicName}" messageId="${messageId}"`
848
+ );
849
+ return;
850
+ }
851
+ const port = process.env.PORT || 3e3;
852
+ const baseUrl = `http://localhost:${port}`;
853
+ for (const route of matchingRoutes) {
854
+ const url = `${baseUrl}${route.urlPath}`;
855
+ console.log(
856
+ `[Dev Mode] Invoking handler: topic="${topicName}" consumer="${route.consumer}" messageId="${messageId}" url="${url}"`
857
+ );
858
+ try {
859
+ const response = await fetch(url, {
860
+ method: "POST",
861
+ headers: {
862
+ "ce-type": CLOUD_EVENT_TYPE_V2BETA,
863
+ "ce-vqsqueuename": topicName,
864
+ "ce-vqsconsumergroup": route.consumer,
865
+ "ce-vqsmessageid": messageId,
866
+ "ce-vqsregion": region
867
+ }
868
+ });
869
+ if (response.ok) {
870
+ try {
871
+ const responseData = await response.json();
872
+ if (responseData.status === "success") {
873
+ console.log(
874
+ `[Dev Mode] \u2713 Message processed successfully: topic="${topicName}" consumer="${route.consumer}" messageId="${messageId}"`
875
+ );
876
+ }
877
+ } catch {
878
+ console.warn(
879
+ `[Dev Mode] Handler returned OK but response was not JSON: topic="${topicName}" consumer="${route.consumer}"`
880
+ );
881
+ }
882
+ } else {
883
+ try {
884
+ const errorData = await response.json();
885
+ console.error(
886
+ `[Dev Mode] \u2717 Handler failed: topic="${topicName}" consumer="${route.consumer}" messageId="${messageId}" error="${errorData.error || response.statusText}"`
887
+ );
888
+ } catch {
889
+ console.error(
890
+ `[Dev Mode] \u2717 Handler failed: topic="${topicName}" consumer="${route.consumer}" messageId="${messageId}" status=${response.status}`
891
+ );
892
+ }
893
+ }
372
894
  } catch (error) {
373
- console.warn("Failed to process multipart message:", error);
374
- await consumeStream(multipartMessage.payload);
895
+ console.error(
896
+ `[Dev Mode] \u2717 HTTP request failed: topic="${topicName}" consumer="${route.consumer}" messageId="${messageId}" url="${url}"`,
897
+ error
898
+ );
899
+ }
900
+ }
901
+ })();
902
+ }
903
+ function clearDevRouteMappings() {
904
+ const g = globalThis;
905
+ delete g[ROUTE_MAPPINGS_KEY];
906
+ }
907
+ if (process.env.NODE_ENV === "test" || process.env.VITEST) {
908
+ globalThis.__clearDevRouteMappings = clearDevRouteMappings;
909
+ }
910
+
911
+ // src/oidc.ts
912
+ import { getVercelOidcToken } from "@vercel/oidc";
913
+
914
+ // src/api-client.ts
915
+ function isDebugEnabled() {
916
+ return process.env.VERCEL_QUEUE_DEBUG === "1" || process.env.VERCEL_QUEUE_DEBUG === "true";
917
+ }
918
+ async function consumeStream(stream) {
919
+ const reader = stream.getReader();
920
+ try {
921
+ while (true) {
922
+ const { done } = await reader.read();
923
+ if (done) break;
924
+ }
925
+ } finally {
926
+ reader.releaseLock();
927
+ }
928
+ }
929
+ function throwCommonHttpError(status, statusText, errorText, operation, badRequestDefault = "Invalid parameters") {
930
+ if (status === 400) {
931
+ throw new BadRequestError(errorText || badRequestDefault);
932
+ }
933
+ if (status === 401) {
934
+ throw new UnauthorizedError(errorText || void 0);
935
+ }
936
+ if (status === 403) {
937
+ throw new ForbiddenError(errorText || void 0);
938
+ }
939
+ if (status >= 500) {
940
+ throw new InternalServerError(
941
+ errorText || `Server error: ${status} ${statusText}`
942
+ );
943
+ }
944
+ throw new Error(`Failed to ${operation}: ${status} ${statusText}`);
945
+ }
946
+ function parseQueueHeaders(headers) {
947
+ const messageId = headers.get("Vqs-Message-Id");
948
+ const deliveryCountStr = headers.get("Vqs-Delivery-Count") || "0";
949
+ const timestamp = headers.get("Vqs-Timestamp");
950
+ const contentType = headers.get("Content-Type") || "application/octet-stream";
951
+ const receiptHandle = headers.get("Vqs-Receipt-Handle");
952
+ if (!messageId || !timestamp || !receiptHandle) {
953
+ return null;
954
+ }
955
+ const deliveryCount = parseInt(deliveryCountStr, 10);
956
+ if (Number.isNaN(deliveryCount)) {
957
+ return null;
958
+ }
959
+ return {
960
+ messageId,
961
+ deliveryCount,
962
+ createdAt: new Date(timestamp),
963
+ contentType,
964
+ receiptHandle
965
+ };
966
+ }
967
+ var DEFAULT_BASE_URL_RESOLVER = (region) => `https://${region}.vercel-queue.com`;
968
+ function resolveBaseUrl(region, resolver) {
969
+ return (resolver ?? DEFAULT_BASE_URL_RESOLVER)(region);
970
+ }
971
+ var BASE_PATH = "/api/v3/topic";
972
+ var ApiClient = class _ApiClient {
973
+ baseUrl;
974
+ customHeaders;
975
+ providedToken;
976
+ resolvedDeploymentId;
977
+ pinSends;
978
+ explicitlyUnpinned;
979
+ transport;
980
+ region;
981
+ baseUrlResolver;
982
+ constructor(options) {
983
+ this.region = options.region;
984
+ this.baseUrlResolver = options.resolveBaseUrl;
985
+ this.baseUrl = resolveBaseUrl(this.region, this.baseUrlResolver);
986
+ this.customHeaders = options.headers || {};
987
+ this.providedToken = options.token;
988
+ this.transport = options.transport || new JsonTransport();
989
+ if (options.deploymentId === null) {
990
+ this.pinSends = false;
991
+ this.explicitlyUnpinned = true;
992
+ } else {
993
+ this.resolvedDeploymentId = options.deploymentId || process.env.VERCEL_DEPLOYMENT_ID;
994
+ this.pinSends = true;
995
+ this.explicitlyUnpinned = false;
996
+ }
997
+ }
998
+ /**
999
+ * Return a new ApiClient targeting the given region, sharing all other
1000
+ * configuration (token, transport, headers, deployment ID, resolver).
1001
+ * Used internally by handleCallback to route follow-up API calls to the
1002
+ * region indicated by the incoming `ce-vqsregion` header.
1003
+ */
1004
+ withRegion(region) {
1005
+ return new _ApiClient({
1006
+ region,
1007
+ resolveBaseUrl: this.baseUrlResolver,
1008
+ token: this.providedToken,
1009
+ headers: { ...this.customHeaders },
1010
+ deploymentId: this.explicitlyUnpinned ? null : this.resolvedDeploymentId,
1011
+ transport: this.transport
1012
+ });
1013
+ }
1014
+ getRegion() {
1015
+ return this.region;
1016
+ }
1017
+ getTransport() {
1018
+ return this.transport;
1019
+ }
1020
+ requireDeploymentId() {
1021
+ if (isDevMode() || this.explicitlyUnpinned || this.resolvedDeploymentId) {
1022
+ return;
1023
+ }
1024
+ throw new Error(
1025
+ 'No deployment ID available. VERCEL_DEPLOYMENT_ID is not set.\n\nThis usually means the code is running outside a Vercel deployment (e.g. during build or in a non-Vercel environment).\n\nTo fix this, create a new QueueClient with an explicit deploymentId:\n new QueueClient({ region: "iad1", deploymentId: "dpl_xxx" })\nOr explicitly opt out of deployment pinning:\n new QueueClient({ region: "iad1", deploymentId: null })'
1026
+ );
1027
+ }
1028
+ getSendDeploymentId() {
1029
+ if (isDevMode()) {
1030
+ return void 0;
1031
+ }
1032
+ this.requireDeploymentId();
1033
+ return this.pinSends ? this.resolvedDeploymentId : void 0;
1034
+ }
1035
+ getConsumeDeploymentId() {
1036
+ if (isDevMode()) {
1037
+ return void 0;
1038
+ }
1039
+ this.requireDeploymentId();
1040
+ return this.resolvedDeploymentId;
1041
+ }
1042
+ async getToken() {
1043
+ if (this.providedToken) {
1044
+ return this.providedToken;
1045
+ }
1046
+ const token = await getVercelOidcToken();
1047
+ if (!token) {
1048
+ throw new Error(
1049
+ "Failed to get OIDC token from Vercel Functions. Make sure you are running in a Vercel Function environment, or provide a token explicitly.\n\nTo set up your environment:\n1. Link your project: 'vercel link'\n2. Pull environment variables: 'vercel env pull'\n3. Run with environment: 'dotenv -e .env.local -- your-command'"
1050
+ );
1051
+ }
1052
+ return token;
1053
+ }
1054
+ buildUrl(queueName, ...pathSegments) {
1055
+ const encodedQueue = encodeURIComponent(queueName);
1056
+ const segments = pathSegments.map((s) => encodeURIComponent(s));
1057
+ const path2 = segments.length > 0 ? "/" + segments.join("/") : "";
1058
+ return `${this.baseUrl}${BASE_PATH}/${encodedQueue}${path2}`;
1059
+ }
1060
+ async fetch(url, init) {
1061
+ const method = init.method || "GET";
1062
+ if (isDebugEnabled()) {
1063
+ const logData = {
1064
+ method,
1065
+ url,
1066
+ headers: init.headers
1067
+ };
1068
+ const body = init.body;
1069
+ if (body !== void 0 && body !== null) {
1070
+ if (body instanceof ArrayBuffer) {
1071
+ logData.bodySize = body.byteLength;
1072
+ } else if (body instanceof Uint8Array) {
1073
+ logData.bodySize = body.byteLength;
1074
+ } else if (typeof body === "string") {
1075
+ logData.bodySize = body.length;
1076
+ } else {
1077
+ logData.bodyType = typeof body;
1078
+ }
375
1079
  }
1080
+ console.debug("[VQS Debug] Request:", JSON.stringify(logData, null, 2));
376
1081
  }
1082
+ init.headers.set("User-Agent", `@vercel/queue/${"0.0.1"}`);
1083
+ init.headers.set("Vqs-Client-Ts", (/* @__PURE__ */ new Date()).toISOString());
1084
+ const response = await fetch(url, init);
1085
+ if (isDebugEnabled()) {
1086
+ const logData = {
1087
+ method,
1088
+ url,
1089
+ status: response.status,
1090
+ statusText: response.statusText,
1091
+ headers: response.headers
1092
+ };
1093
+ console.debug("[VQS Debug] Response:", JSON.stringify(logData, null, 2));
1094
+ }
1095
+ return response;
377
1096
  }
378
- async receiveMessageById(options, transport) {
1097
+ async sendMessage(options) {
1098
+ const transport = this.transport;
379
1099
  const {
380
1100
  queueName,
381
- consumerGroup,
382
- messageId,
383
- visibilityTimeoutSeconds,
384
- skipPayload
1101
+ payload,
1102
+ idempotencyKey,
1103
+ retentionSeconds,
1104
+ delaySeconds,
1105
+ headers: optionHeaders
385
1106
  } = options;
1107
+ const headers = new Headers();
1108
+ if (this.customHeaders) {
1109
+ for (const [name, value] of Object.entries(this.customHeaders)) {
1110
+ headers.append(name, value);
1111
+ }
1112
+ }
1113
+ if (optionHeaders) {
1114
+ const protectedHeaderNames = /* @__PURE__ */ new Set(["authorization", "content-type"]);
1115
+ const isProtectedHeader = (name) => {
1116
+ const lower = name.toLowerCase();
1117
+ if (protectedHeaderNames.has(lower)) return true;
1118
+ return lower.startsWith("vqs-");
1119
+ };
1120
+ for (const [name, value] of Object.entries(optionHeaders)) {
1121
+ if (!isProtectedHeader(name) && value !== void 0) {
1122
+ headers.append(name, value);
1123
+ }
1124
+ }
1125
+ }
1126
+ headers.set("Authorization", `Bearer ${await this.getToken()}`);
1127
+ headers.set("Content-Type", transport.contentType);
1128
+ const deploymentId = this.getSendDeploymentId();
1129
+ if (deploymentId) {
1130
+ headers.set("Vqs-Deployment-Id", deploymentId);
1131
+ }
1132
+ if (idempotencyKey) {
1133
+ headers.set("Vqs-Idempotency-Key", idempotencyKey);
1134
+ }
1135
+ if (retentionSeconds !== void 0) {
1136
+ headers.set("Vqs-Retention-Seconds", retentionSeconds.toString());
1137
+ }
1138
+ if (delaySeconds !== void 0) {
1139
+ headers.set("Vqs-Delay-Seconds", delaySeconds.toString());
1140
+ }
1141
+ const serialized = transport.serialize(payload);
1142
+ const body = Buffer.isBuffer(serialized) ? new Uint8Array(serialized) : serialized;
1143
+ const response = await this.fetch(this.buildUrl(queueName), {
1144
+ method: "POST",
1145
+ body,
1146
+ headers
1147
+ });
1148
+ if (!response.ok) {
1149
+ const errorText = await response.text();
1150
+ if (response.status === 409) {
1151
+ throw new DuplicateMessageError(
1152
+ errorText || "Duplicate idempotency key detected",
1153
+ idempotencyKey
1154
+ );
1155
+ }
1156
+ if (response.status === 502) {
1157
+ throw new ConsumerDiscoveryError(
1158
+ errorText || "Consumer discovery failed",
1159
+ deploymentId
1160
+ );
1161
+ }
1162
+ if (response.status === 503) {
1163
+ throw new ConsumerRegistryNotConfiguredError(
1164
+ errorText || "Consumer registry not configured"
1165
+ );
1166
+ }
1167
+ throwCommonHttpError(
1168
+ response.status,
1169
+ response.statusText,
1170
+ errorText,
1171
+ "send message"
1172
+ );
1173
+ }
1174
+ if (response.status === 202) {
1175
+ return { messageId: null };
1176
+ }
1177
+ const responseData = await response.json();
1178
+ return responseData;
1179
+ }
1180
+ async *receiveMessages(options) {
1181
+ const transport = this.transport;
1182
+ const { queueName, consumerGroup, visibilityTimeoutSeconds, limit } = options;
1183
+ if (limit !== void 0 && (limit < 1 || limit > 10)) {
1184
+ throw new InvalidLimitError(limit);
1185
+ }
386
1186
  const headers = new Headers({
387
- Authorization: `Bearer ${this.token}`,
388
- "Vqs-Queue-Name": queueName,
389
- "Vqs-Consumer-Group": consumerGroup,
390
- Accept: "multipart/mixed"
1187
+ Authorization: `Bearer ${await this.getToken()}`,
1188
+ Accept: "multipart/mixed",
1189
+ ...this.customHeaders
391
1190
  });
392
1191
  if (visibilityTimeoutSeconds !== void 0) {
393
1192
  headers.set(
394
- "Vqs-Visibility-Timeout",
1193
+ "Vqs-Visibility-Timeout-Seconds",
395
1194
  visibilityTimeoutSeconds.toString()
396
1195
  );
397
1196
  }
398
- if (skipPayload) {
399
- headers.set("Vqs-Skip-Payload", "1");
1197
+ if (limit !== void 0) {
1198
+ headers.set("Vqs-Max-Messages", limit.toString());
400
1199
  }
401
- const response = await fetch(
402
- `${this.baseUrl}/api/v2/messages/${encodeURIComponent(messageId)}`,
1200
+ const effectiveDeploymentId = this.getConsumeDeploymentId();
1201
+ if (effectiveDeploymentId) {
1202
+ headers.set("Vqs-Deployment-Id", effectiveDeploymentId);
1203
+ }
1204
+ const response = await this.fetch(
1205
+ this.buildUrl(queueName, "consumer", consumerGroup),
403
1206
  {
404
- method: "GET",
1207
+ method: "POST",
405
1208
  headers
406
1209
  }
407
1210
  );
1211
+ if (response.status === 204) {
1212
+ return;
1213
+ }
408
1214
  if (!response.ok) {
409
- if (response.status === 400) {
410
- const errorText = await response.text();
411
- throw new BadRequestError(errorText || "Invalid parameters");
412
- }
413
- if (response.status === 401) {
414
- throw new UnauthorizedError();
1215
+ const errorText = await response.text();
1216
+ throwCommonHttpError(
1217
+ response.status,
1218
+ response.statusText,
1219
+ errorText,
1220
+ "receive messages"
1221
+ );
1222
+ }
1223
+ for await (const multipartMessage of parseMultipartStream(response)) {
1224
+ try {
1225
+ const parsedHeaders = parseQueueHeaders(multipartMessage.headers);
1226
+ if (!parsedHeaders) {
1227
+ console.warn("Missing required queue headers in multipart part");
1228
+ await consumeStream(multipartMessage.payload);
1229
+ continue;
1230
+ }
1231
+ const deserializedPayload = await transport.deserialize(
1232
+ multipartMessage.payload
1233
+ );
1234
+ const message = {
1235
+ ...parsedHeaders,
1236
+ payload: deserializedPayload
1237
+ };
1238
+ yield message;
1239
+ } catch (error) {
1240
+ console.warn("Failed to process multipart message:", error);
1241
+ await consumeStream(multipartMessage.payload);
415
1242
  }
416
- if (response.status === 403) {
417
- throw new ForbiddenError();
1243
+ }
1244
+ }
1245
+ async receiveMessageById(options) {
1246
+ const transport = this.transport;
1247
+ const { queueName, consumerGroup, messageId, visibilityTimeoutSeconds } = options;
1248
+ const headers = new Headers({
1249
+ Authorization: `Bearer ${await this.getToken()}`,
1250
+ Accept: "multipart/mixed",
1251
+ ...this.customHeaders
1252
+ });
1253
+ if (visibilityTimeoutSeconds !== void 0) {
1254
+ headers.set(
1255
+ "Vqs-Visibility-Timeout-Seconds",
1256
+ visibilityTimeoutSeconds.toString()
1257
+ );
1258
+ }
1259
+ const effectiveDeploymentId = this.getConsumeDeploymentId();
1260
+ if (effectiveDeploymentId) {
1261
+ headers.set("Vqs-Deployment-Id", effectiveDeploymentId);
1262
+ }
1263
+ const response = await this.fetch(
1264
+ this.buildUrl(queueName, "consumer", consumerGroup, "id", messageId),
1265
+ {
1266
+ method: "POST",
1267
+ headers
418
1268
  }
1269
+ );
1270
+ if (!response.ok) {
1271
+ const errorText = await response.text();
419
1272
  if (response.status === 404) {
420
1273
  throw new MessageNotFoundError(messageId);
421
1274
  }
422
- if (response.status === 423) {
423
- const retryAfterHeader = response.headers.get("Retry-After");
424
- let retryAfter;
425
- if (retryAfterHeader) {
426
- const parsed = parseInt(retryAfterHeader, 10);
427
- retryAfter = isNaN(parsed) ? void 0 : parsed;
428
- }
429
- throw new MessageLockedError(messageId, retryAfter);
430
- }
431
1275
  if (response.status === 409) {
1276
+ let errorData = {};
1277
+ try {
1278
+ errorData = JSON.parse(errorText);
1279
+ } catch {
1280
+ }
1281
+ if (errorData.originalMessageId) {
1282
+ throw new MessageNotAvailableError(
1283
+ messageId,
1284
+ `This message was a duplicate - use originalMessageId: ${errorData.originalMessageId}`
1285
+ );
1286
+ }
432
1287
  throw new MessageNotAvailableError(messageId);
433
1288
  }
434
- if (response.status >= 500) {
435
- throw new InternalServerError(
436
- `Server error: ${response.status} ${response.statusText}`
437
- );
1289
+ if (response.status === 410) {
1290
+ throw new MessageAlreadyProcessedError(messageId);
438
1291
  }
439
- throw new Error(
440
- `Failed to receive message by ID: ${response.status} ${response.statusText}`
1292
+ throwCommonHttpError(
1293
+ response.status,
1294
+ response.statusText,
1295
+ errorText,
1296
+ "receive message by ID"
441
1297
  );
442
1298
  }
443
- if (skipPayload && response.status === 204) {
444
- const parsedHeaders = parseQueueHeaders(response.headers);
1299
+ for await (const multipartMessage of parseMultipartStream(response)) {
1300
+ const parsedHeaders = parseQueueHeaders(multipartMessage.headers);
445
1301
  if (!parsedHeaders) {
1302
+ await consumeStream(multipartMessage.payload);
446
1303
  throw new MessageCorruptedError(
447
1304
  messageId,
448
- "Missing required queue headers in 204 response"
1305
+ "Missing required queue headers in response"
449
1306
  );
450
1307
  }
1308
+ const deserializedPayload = await transport.deserialize(
1309
+ multipartMessage.payload
1310
+ );
451
1311
  const message = {
452
1312
  ...parsedHeaders,
453
- payload: void 0
1313
+ payload: deserializedPayload
454
1314
  };
455
1315
  return { message };
456
1316
  }
457
- if (!transport) {
458
- throw new Error("Transport is required when skipPayload is not true");
459
- }
460
- try {
461
- for await (const multipartMessage of parseMultipartStream(response)) {
462
- try {
463
- const parsedHeaders = parseQueueHeaders(multipartMessage.headers);
464
- if (!parsedHeaders) {
465
- console.warn("Missing required queue headers in multipart part");
466
- await consumeStream(multipartMessage.payload);
467
- continue;
468
- }
469
- const deserializedPayload = await transport.deserialize(
470
- multipartMessage.payload
471
- );
472
- const message = {
473
- ...parsedHeaders,
474
- payload: deserializedPayload
475
- };
476
- return { message };
477
- } catch (error) {
478
- console.warn("Failed to deserialize message by ID:", error);
479
- await consumeStream(multipartMessage.payload);
480
- throw new MessageCorruptedError(
481
- messageId,
482
- `Failed to deserialize payload: ${error}`
483
- );
484
- }
485
- }
486
- } catch (error) {
487
- if (error instanceof MessageCorruptedError) {
488
- throw error;
489
- }
490
- throw new MessageCorruptedError(
491
- messageId,
492
- `Failed to parse multipart response: ${error}`
493
- );
494
- }
495
1317
  throw new MessageNotFoundError(messageId);
496
1318
  }
497
- /**
498
- * Delete a message (acknowledge processing)
499
- * @param options Delete message options
500
- * @returns Promise with delete status
501
- * @throws {MessageNotFoundError} When the message doesn't exist (404)
502
- * @throws {MessageNotAvailableError} When message can't be deleted (409)
503
- * @throws {BadRequestError} When ticket is missing or invalid (400)
504
- * @throws {UnauthorizedError} When authentication fails
505
- * @throws {ForbiddenError} When access is denied (environment mismatch)
506
- * @throws {InternalServerError} When server encounters an error
507
- */
508
- async deleteMessage(options) {
509
- const { queueName, consumerGroup, messageId, ticket } = options;
510
- const response = await fetch(
511
- `${this.baseUrl}/api/v2/messages/${encodeURIComponent(messageId)}`,
1319
+ async acknowledgeMessage(options) {
1320
+ const { queueName, consumerGroup, receiptHandle } = options;
1321
+ const headers = new Headers({
1322
+ Authorization: `Bearer ${await this.getToken()}`,
1323
+ ...this.customHeaders
1324
+ });
1325
+ const effectiveDeploymentId = this.getConsumeDeploymentId();
1326
+ if (effectiveDeploymentId) {
1327
+ headers.set("Vqs-Deployment-Id", effectiveDeploymentId);
1328
+ }
1329
+ const response = await this.fetch(
1330
+ this.buildUrl(
1331
+ queueName,
1332
+ "consumer",
1333
+ consumerGroup,
1334
+ "lease",
1335
+ receiptHandle
1336
+ ),
512
1337
  {
513
1338
  method: "DELETE",
514
- headers: new Headers({
515
- Authorization: `Bearer ${this.token}`,
516
- "Vqs-Queue-Name": queueName,
517
- "Vqs-Consumer-Group": consumerGroup,
518
- "Vqs-Ticket": ticket
519
- })
1339
+ headers
520
1340
  }
521
1341
  );
522
- if (!response.ok) {
523
- if (response.status === 400) {
524
- throw new BadRequestError("Missing or invalid ticket");
525
- }
526
- if (response.status === 401) {
527
- throw new UnauthorizedError();
528
- }
529
- if (response.status === 403) {
530
- throw new ForbiddenError();
531
- }
1342
+ if (!response.ok) {
1343
+ const errorText = await response.text();
532
1344
  if (response.status === 404) {
533
- throw new MessageNotFoundError(messageId);
1345
+ throw new MessageNotFoundError(receiptHandle);
534
1346
  }
535
1347
  if (response.status === 409) {
536
1348
  throw new MessageNotAvailableError(
537
- messageId,
538
- "Invalid ticket, message not in correct state, or already processed"
539
- );
540
- }
541
- if (response.status >= 500) {
542
- throw new InternalServerError(
543
- `Server error: ${response.status} ${response.statusText}`
1349
+ receiptHandle,
1350
+ errorText || "Invalid receipt handle, message not in correct state, or already processed"
544
1351
  );
545
1352
  }
546
- throw new Error(
547
- `Failed to delete message: ${response.status} ${response.statusText}`
1353
+ throwCommonHttpError(
1354
+ response.status,
1355
+ response.statusText,
1356
+ errorText,
1357
+ "acknowledge message",
1358
+ "Missing or invalid receipt handle"
548
1359
  );
549
1360
  }
550
- return { deleted: true };
1361
+ return { acknowledged: true };
551
1362
  }
552
- /**
553
- * Change the visibility timeout of a message
554
- * @param options Change visibility options
555
- * @returns Promise with update status
556
- * @throws {MessageNotFoundError} When the message doesn't exist (404)
557
- * @throws {MessageNotAvailableError} When message can't be updated (409)
558
- * @throws {BadRequestError} When ticket is missing or visibility timeout invalid (400)
559
- * @throws {UnauthorizedError} When authentication fails
560
- * @throws {ForbiddenError} When access is denied (environment mismatch)
561
- * @throws {InternalServerError} When server encounters an error
562
- */
563
1363
  async changeVisibility(options) {
564
1364
  const {
565
1365
  queueName,
566
1366
  consumerGroup,
567
- messageId,
568
- ticket,
1367
+ receiptHandle,
569
1368
  visibilityTimeoutSeconds
570
1369
  } = options;
571
- const response = await fetch(
572
- `${this.baseUrl}/api/v2/messages/${encodeURIComponent(messageId)}`,
1370
+ const headers = new Headers({
1371
+ Authorization: `Bearer ${await this.getToken()}`,
1372
+ "Content-Type": "application/json",
1373
+ ...this.customHeaders
1374
+ });
1375
+ const effectiveDeploymentId = this.getConsumeDeploymentId();
1376
+ if (effectiveDeploymentId) {
1377
+ headers.set("Vqs-Deployment-Id", effectiveDeploymentId);
1378
+ }
1379
+ const response = await this.fetch(
1380
+ this.buildUrl(
1381
+ queueName,
1382
+ "consumer",
1383
+ consumerGroup,
1384
+ "lease",
1385
+ receiptHandle
1386
+ ),
573
1387
  {
574
1388
  method: "PATCH",
575
- headers: new Headers({
576
- Authorization: `Bearer ${this.token}`,
577
- "Vqs-Queue-Name": queueName,
578
- "Vqs-Consumer-Group": consumerGroup,
579
- "Vqs-Ticket": ticket,
580
- "Vqs-Visibility-Timeout": visibilityTimeoutSeconds.toString()
581
- })
1389
+ headers,
1390
+ body: JSON.stringify({ visibilityTimeoutSeconds })
582
1391
  }
583
1392
  );
584
1393
  if (!response.ok) {
585
- if (response.status === 400) {
586
- throw new BadRequestError(
587
- "Missing ticket or invalid visibility timeout"
588
- );
589
- }
590
- if (response.status === 401) {
591
- throw new UnauthorizedError();
592
- }
593
- if (response.status === 403) {
594
- throw new ForbiddenError();
595
- }
1394
+ const errorText = await response.text();
596
1395
  if (response.status === 404) {
597
- throw new MessageNotFoundError(messageId);
1396
+ throw new MessageNotFoundError(receiptHandle);
598
1397
  }
599
1398
  if (response.status === 409) {
600
1399
  throw new MessageNotAvailableError(
601
- messageId,
602
- "Invalid ticket, message not in correct state, or already processed"
603
- );
604
- }
605
- if (response.status >= 500) {
606
- throw new InternalServerError(
607
- `Server error: ${response.status} ${response.statusText}`
1400
+ receiptHandle,
1401
+ errorText || "Invalid receipt handle, message not in correct state, or already processed"
608
1402
  );
609
1403
  }
610
- throw new Error(
611
- `Failed to change visibility: ${response.status} ${response.statusText}`
1404
+ throwCommonHttpError(
1405
+ response.status,
1406
+ response.statusText,
1407
+ errorText,
1408
+ "change visibility",
1409
+ "Missing receipt handle or invalid visibility timeout"
612
1410
  );
613
1411
  }
614
- return { updated: true };
1412
+ return { success: true };
615
1413
  }
616
1414
  };
617
1415
 
618
- // src/consumer-group.ts
619
- var ConsumerGroup = class {
620
- client;
621
- topicName;
622
- consumerGroupName;
623
- visibilityTimeout;
624
- refreshInterval;
625
- transport;
626
- /**
627
- * Create a new ConsumerGroup instance
628
- * @param client QueueClient instance to use for API calls
629
- * @param topicName Name of the topic to consume from
630
- * @param consumerGroupName Name of the consumer group
631
- * @param options Optional configuration
632
- */
633
- constructor(client, topicName, consumerGroupName, options = {}) {
634
- this.client = client;
635
- this.topicName = topicName;
636
- this.consumerGroupName = consumerGroupName;
637
- this.visibilityTimeout = options.visibilityTimeoutSeconds || 30;
638
- this.refreshInterval = options.refreshInterval || 10;
639
- this.transport = options.transport || new JsonTransport();
1416
+ // src/client.ts
1417
+ var apiClients = /* @__PURE__ */ new WeakMap();
1418
+ function getApiClient(client) {
1419
+ const api = apiClients.get(client);
1420
+ if (!api) {
1421
+ throw new Error("QueueClient not initialized");
1422
+ }
1423
+ return api;
1424
+ }
1425
+ var QueueClient = class {
1426
+ constructor(options) {
1427
+ apiClients.set(this, new ApiClient(options));
640
1428
  }
641
1429
  /**
642
- * Starts a background loop that periodically extends the visibility timeout for a message.
643
- * This prevents the message from becoming visible to other consumers while it's being processed.
644
- *
645
- * The extension loop runs every `refreshInterval` seconds and updates the message's
646
- * visibility timeout to `visibilityTimeout` seconds from the current time.
1430
+ * Send a message to a topic.
647
1431
  *
648
- * @param messageId - The unique identifier of the message to extend visibility for
649
- * @param ticket - The receipt ticket that proves ownership of the message
650
- * @returns A function that when called will stop the extension loop
1432
+ * This is an arrow function property so it can be destructured:
1433
+ * ```typescript
1434
+ * const { send } = new QueueClient({ region: process.env.QUEUE_REGION! });
1435
+ * await send("my-topic", payload);
1436
+ * ```
651
1437
  *
652
- * @remarks
653
- * - The first extension attempt occurs after `refreshInterval` seconds, not immediately
654
- * - If an extension fails, the loop terminates with an error logged to console
655
- * - The returned stop function is idempotent - calling it multiple times is safe
656
- * - By default, the stop function returns immediately without waiting for in-flight
657
- * - Pass `true` to the stop function to wait for any in-flight extension to complete
1438
+ * @param topicName - Name of the topic (pattern: `[A-Za-z0-9_-]+`)
1439
+ * @param payload - The data to send (serialized via the configured transport)
1440
+ * @param options - Optional send options (idempotencyKey, retentionSeconds, delaySeconds, headers)
1441
+ * @returns `{ messageId }` `messageId` is `null` when the server accepted
1442
+ * the message for deferred processing (no ID available yet)
658
1443
  */
659
- startVisibilityExtension(messageId, ticket) {
660
- let isRunning = true;
661
- let resolveLifecycle;
662
- let timeoutId = null;
663
- const lifecyclePromise = new Promise((resolve) => {
664
- resolveLifecycle = resolve;
1444
+ send = async (topicName, payload, options) => {
1445
+ const api = getApiClient(this);
1446
+ const result = await api.sendMessage({
1447
+ queueName: topicName,
1448
+ payload,
1449
+ idempotencyKey: options?.idempotencyKey,
1450
+ retentionSeconds: options?.retentionSeconds,
1451
+ delaySeconds: options?.delaySeconds,
1452
+ headers: options?.headers
665
1453
  });
666
- const extend = async () => {
667
- if (!isRunning) {
668
- resolveLifecycle();
669
- return;
670
- }
671
- try {
672
- await this.client.changeVisibility({
673
- queueName: this.topicName,
674
- consumerGroup: this.consumerGroupName,
675
- messageId,
676
- ticket,
677
- visibilityTimeoutSeconds: this.visibilityTimeout
678
- });
679
- if (isRunning) {
680
- timeoutId = setTimeout(() => extend(), this.refreshInterval * 1e3);
681
- } else {
682
- resolveLifecycle();
683
- }
684
- } catch (error) {
685
- console.error(
686
- `Failed to extend visibility for message ${messageId}:`,
687
- error
688
- );
689
- resolveLifecycle();
690
- }
691
- };
692
- timeoutId = setTimeout(() => extend(), this.refreshInterval * 1e3);
693
- return async (waitForCompletion = false) => {
694
- isRunning = false;
695
- if (timeoutId) {
696
- clearTimeout(timeoutId);
697
- timeoutId = null;
698
- }
699
- if (waitForCompletion) {
700
- await lifecyclePromise;
701
- } else {
702
- resolveLifecycle();
703
- }
704
- };
705
- }
1454
+ if (result.messageId && isDevMode()) {
1455
+ triggerDevCallbacks(
1456
+ topicName,
1457
+ result.messageId,
1458
+ api.getRegion(),
1459
+ options?.delaySeconds
1460
+ );
1461
+ }
1462
+ return { messageId: result.messageId };
1463
+ };
706
1464
  /**
707
- * Process a single message with the given handler
708
- * @param message The message to process
709
- * @param handler Function to process the message
1465
+ * Receive and process messages from a topic.
1466
+ *
1467
+ * Each message is automatically locked, kept alive via periodic visibility
1468
+ * extensions during processing, and acknowledged upon successful handler completion.
1469
+ * The handler is not called when the queue is empty — check `result.ok` instead.
1470
+ *
1471
+ * This is an arrow function property so it can be destructured:
1472
+ * ```typescript
1473
+ * const { receive } = new QueueClient({ region: process.env.QUEUE_REGION! });
1474
+ * const result = await receive("my-topic", "my-group", handler);
1475
+ * if (!result.ok) console.log(result.reason);
1476
+ * ```
1477
+ *
1478
+ * @param topicName - Name of the topic (pattern: `[A-Za-z0-9_-]+`)
1479
+ * @param consumerGroup - Name of the consumer group (pattern: `[A-Za-z0-9_-]+`)
1480
+ * @param handler - Function to process each message payload and metadata.
1481
+ * Not called when the queue is empty.
1482
+ * @param options - Optional receive options (visibilityTimeoutSeconds, limit, or messageId)
1483
+ * @returns Discriminated result: `{ ok: true }` on success, `{ ok: false, reason }` otherwise
710
1484
  */
711
- async processMessage(message, handler) {
712
- const stopExtension = this.startVisibilityExtension(
713
- message.messageId,
714
- message.ticket
1485
+ receive = async (topicName, consumerGroup, handler, options) => {
1486
+ const api = getApiClient(this);
1487
+ const topic = new Topic(api, topicName);
1488
+ const visibilityTimeoutSeconds = options && "visibilityTimeoutSeconds" in options ? options.visibilityTimeoutSeconds : void 0;
1489
+ const consumer = topic.consumerGroup(
1490
+ consumerGroup,
1491
+ visibilityTimeoutSeconds !== void 0 ? { visibilityTimeoutSeconds } : {}
715
1492
  );
716
1493
  try {
717
- const result = await handler(message.payload, {
718
- messageId: message.messageId,
719
- deliveryCount: message.deliveryCount,
720
- timestamp: message.timestamp
721
- });
722
- await stopExtension();
723
- if (result && "timeoutSeconds" in result) {
724
- await this.client.changeVisibility({
725
- queueName: this.topicName,
726
- consumerGroup: this.consumerGroupName,
727
- messageId: message.messageId,
728
- ticket: message.ticket,
729
- visibilityTimeoutSeconds: result.timeoutSeconds
1494
+ let count;
1495
+ const retry = options?.retry;
1496
+ if (options && "messageId" in options) {
1497
+ count = await consumer.consume(handler, {
1498
+ messageId: options.messageId,
1499
+ retry
730
1500
  });
731
1501
  } else {
732
- await this.client.deleteMessage({
733
- queueName: this.topicName,
734
- consumerGroup: this.consumerGroupName,
735
- messageId: message.messageId,
736
- ticket: message.ticket
1502
+ const limit = options && "limit" in options ? options.limit : void 0;
1503
+ count = await consumer.consume(handler, {
1504
+ ...limit !== void 0 ? { limit } : {},
1505
+ retry
737
1506
  });
738
1507
  }
739
- } catch (error) {
740
- await stopExtension();
741
- if (this.transport.finalize && message.payload !== void 0 && message.payload !== null) {
742
- try {
743
- await this.transport.finalize(message.payload);
744
- } catch (finalizeError) {
745
- console.warn("Failed to finalize message payload:", finalizeError);
746
- }
1508
+ if (count === 0) {
1509
+ return { ok: false, reason: "empty" };
747
1510
  }
748
- throw error;
749
- }
750
- }
751
- async consume(handler, options) {
752
- if (options?.messageId) {
753
- if (options.skipPayload) {
754
- const response = await this.client.receiveMessageById(
755
- {
756
- queueName: this.topicName,
757
- consumerGroup: this.consumerGroupName,
758
- messageId: options.messageId,
759
- visibilityTimeoutSeconds: this.visibilityTimeout,
760
- skipPayload: true
761
- },
762
- this.transport
763
- );
764
- await this.processMessage(
765
- response.message,
766
- handler
767
- );
768
- } else {
769
- const response = await this.client.receiveMessageById(
770
- {
771
- queueName: this.topicName,
772
- consumerGroup: this.consumerGroupName,
773
- messageId: options.messageId,
774
- visibilityTimeoutSeconds: this.visibilityTimeout
775
- },
776
- this.transport
777
- );
778
- await this.processMessage(
779
- response.message,
780
- handler
781
- );
1511
+ return { ok: true };
1512
+ } catch (error) {
1513
+ if (options && "messageId" in options && error instanceof MessageNotFoundError) {
1514
+ return { ok: false, reason: "not_found", messageId: options.messageId };
782
1515
  }
783
- } else {
784
- let messageFound = false;
785
- for await (const message of this.client.receiveMessages(
786
- {
787
- queueName: this.topicName,
788
- consumerGroup: this.consumerGroupName,
789
- visibilityTimeoutSeconds: this.visibilityTimeout,
790
- limit: 1
791
- },
792
- this.transport
793
- )) {
794
- messageFound = true;
795
- await this.processMessage(message, handler);
796
- break;
1516
+ if (options && "messageId" in options && error instanceof MessageNotAvailableError) {
1517
+ return {
1518
+ ok: false,
1519
+ reason: "not_available",
1520
+ messageId: options.messageId
1521
+ };
797
1522
  }
798
- if (!messageFound) {
799
- throw new Error("No messages available");
1523
+ if (options && "messageId" in options && error instanceof MessageAlreadyProcessedError) {
1524
+ return {
1525
+ ok: false,
1526
+ reason: "already_processed",
1527
+ messageId: options.messageId
1528
+ };
800
1529
  }
1530
+ throw error;
801
1531
  }
802
- }
803
- /**
804
- * Get the consumer group name
805
- */
806
- get name() {
807
- return this.consumerGroupName;
808
- }
809
- /**
810
- * Get the topic name this consumer group is subscribed to
811
- */
812
- get topic() {
813
- return this.topicName;
814
- }
815
- };
816
-
817
- // src/topic.ts
818
- var Topic = class {
819
- client;
820
- topicName;
821
- transport;
822
- /**
823
- * Create a new Topic instance
824
- * @param client QueueClient instance to use for API calls
825
- * @param topicName Name of the topic to work with
826
- * @param transport Optional serializer/deserializer for the payload (defaults to JSON)
827
- */
828
- constructor(client, topicName, transport) {
829
- this.client = client;
830
- this.topicName = topicName;
831
- this.transport = transport || new JsonTransport();
832
- }
833
- /**
834
- * Publish a message to the topic
835
- * @param payload The data to publish
836
- * @param options Optional publish options
837
- * @returns An object containing the message ID
838
- * @throws {BadRequestError} When request parameters are invalid
839
- * @throws {UnauthorizedError} When authentication fails
840
- * @throws {ForbiddenError} When access is denied (environment mismatch)
841
- * @throws {InternalServerError} When server encounters an error
842
- */
843
- async publish(payload, options) {
844
- const result = await this.client.sendMessage(
845
- {
846
- queueName: this.topicName,
847
- payload,
848
- idempotencyKey: options?.idempotencyKey,
849
- retentionSeconds: options?.retentionSeconds
850
- },
851
- this.transport
852
- );
853
- return { messageId: result.messageId };
854
- }
855
- /**
856
- * Create a consumer group for this topic
857
- * @param consumerGroupName Name of the consumer group
858
- * @param options Optional configuration for the consumer group
859
- * @returns A ConsumerGroup instance
860
- */
861
- consumerGroup(consumerGroupName, options) {
862
- const consumerOptions = {
863
- ...options,
864
- transport: options?.transport || this.transport
865
- };
866
- return new ConsumerGroup(
867
- this.client,
868
- this.topicName,
869
- consumerGroupName,
870
- consumerOptions
871
- );
872
- }
873
- /**
874
- * Get the topic name
875
- */
876
- get name() {
877
- return this.topicName;
878
- }
1532
+ };
879
1533
  /**
880
- * Get the transport used by this topic
1534
+ * Create a Web API route handler for processing queue callback messages.
1535
+ *
1536
+ * Parses incoming `Request` as a CloudEvent and invokes the handler.
1537
+ * For use on Vercel — Vercel invokes this route when messages are available.
1538
+ *
1539
+ * This is an arrow function property so it can be destructured:
1540
+ * ```typescript
1541
+ * const { handleCallback } = new QueueClient({ region: process.env.QUEUE_REGION! });
1542
+ * export const POST = handleCallback(handler);
1543
+ * ```
1544
+ *
1545
+ * @param handler - Function to process the message payload and metadata
1546
+ * @param options - Optional configuration
1547
+ * @param options.visibilityTimeoutSeconds - Message lock duration (default: 300, max: 3600)
1548
+ * @param options.retry - Called when the handler throws. Return `{ afterSeconds: N }` to
1549
+ * reschedule the message for redelivery after N seconds.
1550
+ * @returns A `(request: Request) => Promise<Response>` route handler
881
1551
  */
882
- get serializer() {
883
- return this.transport;
884
- }
885
- };
886
-
887
- // src/factory.ts
888
- async function send(topicName, payload, options) {
889
- const transport = options?.transport || new JsonTransport();
890
- const client = QueueClient._getDefaultInstance();
891
- const result = await client.sendMessage(
892
- {
893
- queueName: topicName,
894
- payload,
895
- idempotencyKey: options?.idempotencyKey,
896
- retentionSeconds: options?.retentionSeconds
897
- },
898
- transport
899
- );
900
- return { messageId: result.messageId };
901
- }
902
- async function receive(topicName, consumerGroup, handler, options) {
903
- const transport = options?.transport || new JsonTransport();
904
- const client = QueueClient._getDefaultInstance();
905
- const topic = new Topic(client, topicName, transport);
906
- const { messageId, skipPayload, ...consumerGroupOptions } = options || {};
907
- const consumer = topic.consumerGroup(consumerGroup, consumerGroupOptions);
908
- if (messageId) {
909
- if (skipPayload) {
910
- return consumer.consume(handler, {
911
- messageId,
912
- skipPayload: true
913
- });
914
- } else {
915
- return consumer.consume(handler, { messageId });
916
- }
917
- } else {
918
- return consumer.consume(handler);
919
- }
920
- }
921
-
922
- // src/callback.ts
923
- async function parseCallbackRequest(request) {
924
- const contentType = request.headers.get("content-type");
925
- if (!contentType || !contentType.includes("application/cloudevents+json")) {
926
- throw new Error(
927
- "Invalid content type: expected 'application/cloudevents+json'"
928
- );
929
- }
930
- let cloudEvent;
931
- try {
932
- cloudEvent = await request.json();
933
- } catch (error) {
934
- throw new Error("Failed to parse CloudEvent from request body");
935
- }
936
- if (!cloudEvent.type || !cloudEvent.source || !cloudEvent.id || typeof cloudEvent.data !== "object" || cloudEvent.data == null) {
937
- throw new Error("Invalid CloudEvent: missing required fields");
938
- }
939
- if (cloudEvent.type !== "com.vercel.queue.v1beta") {
940
- throw new Error(
941
- `Invalid CloudEvent type: expected 'com.vercel.queue.v1beta', got '${cloudEvent.type}'`
942
- );
943
- }
944
- const missingFields = [];
945
- if (!("queueName" in cloudEvent.data)) missingFields.push("queueName");
946
- if (!("consumerGroup" in cloudEvent.data))
947
- missingFields.push("consumerGroup");
948
- if (!("messageId" in cloudEvent.data)) missingFields.push("messageId");
949
- if (missingFields.length > 0) {
950
- throw new Error(
951
- `Missing required CloudEvent data fields: ${missingFields.join(", ")}`
952
- );
953
- }
954
- const { messageId, queueName, consumerGroup } = cloudEvent.data;
955
- return {
956
- queueName,
957
- consumerGroup,
958
- messageId
959
- };
960
- }
961
- function handleCallback(handlers) {
962
- return async (request) => {
963
- try {
964
- const { queueName, consumerGroup, messageId } = await parseCallbackRequest(request);
965
- const topicHandler = handlers[queueName];
966
- if (!topicHandler) {
967
- const availableTopics = Object.keys(handlers).join(", ");
1552
+ handleCallback = (handler, options) => {
1553
+ return async (request) => {
1554
+ try {
1555
+ const parsed = await parseCallback(request);
1556
+ await handleCallback(handler, parsed, {
1557
+ client: this,
1558
+ visibilityTimeoutSeconds: options?.visibilityTimeoutSeconds,
1559
+ retry: options?.retry
1560
+ });
1561
+ return Response.json({ status: "success" });
1562
+ } catch (error) {
1563
+ console.error("Queue callback error:", error);
1564
+ if (error instanceof Error && (error.message.includes("Invalid content type") || error.message.includes("Invalid CloudEvent") || error.message.includes("Missing required CloudEvent") || error.message.includes("Failed to parse CloudEvent") || error.message.includes("Binary mode callback"))) {
1565
+ return Response.json({ error: error.message }, { status: 400 });
1566
+ }
968
1567
  return Response.json(
969
- {
970
- error: `No handler found for topic: ${queueName}`,
971
- availableTopics
972
- },
973
- { status: 404 }
1568
+ { error: "Failed to process queue message" },
1569
+ { status: 500 }
974
1570
  );
975
1571
  }
976
- const consumerGroupHandler = topicHandler[consumerGroup];
977
- if (!consumerGroupHandler) {
978
- const availableGroups = Object.keys(topicHandler).join(", ");
979
- return Response.json(
980
- {
981
- error: `No handler found for consumer group "${consumerGroup}" in topic "${queueName}".`,
982
- availableGroups
983
- },
984
- { status: 404 }
985
- );
1572
+ };
1573
+ };
1574
+ /**
1575
+ * Create a Connect-style route handler for processing queue callback messages.
1576
+ * For use on Vercel — Vercel invokes this route when messages are available.
1577
+ *
1578
+ * For frameworks using the `(req, res)` middleware pattern where `req.body`
1579
+ * is pre-parsed (Next.js Pages Router, etc.).
1580
+ *
1581
+ * This is an arrow function property so it can be destructured:
1582
+ * ```typescript
1583
+ * const { handleNodeCallback } = new QueueClient({ region: process.env.QUEUE_REGION! });
1584
+ * app.post("/api/queue", handleNodeCallback(handler));
1585
+ * ```
1586
+ *
1587
+ * @param handler - Function to process the message payload and metadata
1588
+ * @param options - Optional configuration
1589
+ * @param options.visibilityTimeoutSeconds - Message lock duration (default: 300, max: 3600)
1590
+ * @param options.retry - Called when the handler throws. Return `{ afterSeconds: N }` to
1591
+ * reschedule the message for redelivery after N seconds.
1592
+ * @returns A `(req, res) => Promise<void>` route handler
1593
+ */
1594
+ handleNodeCallback = (handler, options) => {
1595
+ return async (req, res) => {
1596
+ if (req.method !== "POST") {
1597
+ res.status(200).end();
1598
+ return;
986
1599
  }
987
- const client = new QueueClient();
988
- const topic = new Topic(client, queueName);
989
- const cg = topic.consumerGroup(consumerGroup);
990
- await cg.consume(consumerGroupHandler, { messageId });
991
- return Response.json({ status: "success" });
992
- } catch (error) {
993
- console.error("Queue callback error:", error);
994
- if (error instanceof Error && (error.message.includes("Missing required CloudEvent data fields") || error.message.includes("Invalid CloudEvent") || error.message.includes("Invalid CloudEvent type") || error.message.includes("Invalid content type") || error.message.includes("Failed to parse CloudEvent"))) {
995
- return Response.json({ error: error.message }, { status: 400 });
1600
+ try {
1601
+ const parsed = parseRawCallback(req.body, req.headers);
1602
+ await handleCallback(handler, parsed, {
1603
+ client: this,
1604
+ visibilityTimeoutSeconds: options?.visibilityTimeoutSeconds,
1605
+ retry: options?.retry
1606
+ });
1607
+ res.status(200).json({ status: "success" });
1608
+ } catch (error) {
1609
+ console.error("Queue callback error:", error);
1610
+ if (error instanceof Error && (error.message.includes("Invalid content type") || error.message.includes("Invalid CloudEvent") || error.message.includes("Missing required CloudEvent") || error.message.includes("Failed to parse CloudEvent") || error.message.includes("Binary mode callback"))) {
1611
+ res.status(400).json({ error: error.message });
1612
+ return;
1613
+ }
1614
+ res.status(500).json({ error: "Failed to process queue message" });
996
1615
  }
997
- return Response.json(
998
- { error: "Failed to process queue message" },
999
- { status: 500 }
1000
- );
1001
- }
1616
+ };
1002
1617
  };
1003
- }
1618
+ };
1004
1619
  export {
1005
1620
  BadRequestError,
1006
1621
  BufferTransport,
1622
+ CLOUD_EVENT_TYPE_V1BETA,
1623
+ CLOUD_EVENT_TYPE_V2BETA,
1624
+ ConsumerDiscoveryError,
1625
+ ConsumerRegistryNotConfiguredError,
1626
+ DuplicateMessageError,
1007
1627
  ForbiddenError,
1008
1628
  InternalServerError,
1009
1629
  InvalidLimitError,
1010
1630
  JsonTransport,
1631
+ MessageAlreadyProcessedError,
1011
1632
  MessageCorruptedError,
1012
1633
  MessageLockedError,
1013
1634
  MessageNotAvailableError,
1014
1635
  MessageNotFoundError,
1636
+ QueueClient,
1015
1637
  QueueEmptyError,
1016
1638
  StreamTransport,
1017
1639
  UnauthorizedError,
1018
- handleCallback,
1019
- receive,
1020
- send
1640
+ parseCallback,
1641
+ parseRawCallback
1021
1642
  };
1022
1643
  //# sourceMappingURL=index.mjs.map