@vercel/queue 0.0.0-alpha.9 → 0.0.2

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