@vercel/queue 0.0.0-alpha.4 → 0.0.0-alpha.40

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,115 +1,82 @@
1
- // src/client.ts
2
- import { parseMultipartStream } from "mixpart";
3
-
4
- // src/local.ts
5
- import { spawn } from "child_process";
6
- function isLocalhostWithPort(url) {
1
+ // src/transports.ts
2
+ async function streamToBuffer(stream) {
3
+ let totalLength = 0;
4
+ const reader = stream.getReader();
5
+ const chunks = [];
7
6
  try {
8
- const parsedUrl = new URL(url);
9
- const isLocalhost = parsedUrl.hostname === "localhost";
10
- const port = parsedUrl.port ? parseInt(parsedUrl.port, 10) : 0;
11
- return { isLocalhost, port };
12
- } catch {
13
- return { isLocalhost: false };
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();
14
15
  }
16
+ return Buffer.concat(chunks, totalLength);
15
17
  }
16
- function isSupportedPlatform() {
17
- const platform = process.platform;
18
- return platform === "darwin" || platform === "linux";
19
- }
20
- function processDevelopmentCallbacks(callbacks) {
21
- const isDevelopment = process.env.NODE_ENV === "development";
22
- if (!isDevelopment) {
23
- return [];
18
+ var JsonTransport = class {
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;
24
31
  }
25
- if (!isSupportedPlatform()) {
26
- const hasLocalhostCallbacks = Object.values(callbacks).some((config) => {
27
- const { isLocalhost } = isLocalhostWithPort(config.url);
28
- return isLocalhost;
29
- });
30
- if (hasLocalhostCallbacks) {
31
- console.warn(
32
- `Queue Development Mode: Localhost callbacks are not supported on ${process.platform}. Localhost callback handling requires bash, nc, and curl which are available on macOS and Linux only. Consider using a production callback URL or developing on a supported platform.`
33
- );
34
- }
35
- return [];
32
+ serialize(value) {
33
+ return Buffer.from(JSON.stringify(value, this.replacer), "utf8");
36
34
  }
37
- const localhostCallbacks = [];
38
- Object.entries(callbacks).forEach(([group, config]) => {
39
- const { isLocalhost, port } = isLocalhostWithPort(config.url);
40
- if (isLocalhost && port && port > 0) {
41
- localhostCallbacks.push({ group, config, port });
42
- } else {
43
- console.warn(
44
- `Queue Development Mode: Skipping non-localhost callback for group "${group}": ${config.url}. Only localhost callbacks with explicit ports are supported in development.`
45
- );
46
- }
47
- });
48
- return localhostCallbacks;
49
- }
50
- function fireLocalhostCallbacks(localhostCallbacks, queueName, responseData) {
51
- localhostCallbacks.forEach(({ group, config, port }) => {
52
- const callbackHeaders = new Headers();
53
- callbackHeaders.set("Vqs-Message-Id", responseData.messageId);
54
- callbackHeaders.set("Vqs-Queue-Name", queueName);
55
- callbackHeaders.set("Vqs-Consumer-Group", group);
56
- fireAndForgetWaitForHttpReady(
57
- config.url,
58
- port,
59
- config.delay || 0,
60
- 3,
61
- // Default retry frequency
62
- callbackHeaders
63
- );
64
- });
65
- }
66
- function fireAndForgetWaitForHttpReady(url, port, initialDelaySeconds = 0, retryFrequencySeconds = 3, headers) {
67
- if (!isSupportedPlatform()) {
68
- console.warn(
69
- `Queue: fireAndForgetWaitForHttpReady is not supported on ${process.platform}. This function requires bash, nc, and curl which are available on macOS and Linux only.`
70
- );
71
- return;
35
+ async deserialize(stream) {
36
+ const buffer = await streamToBuffer(stream);
37
+ return JSON.parse(buffer.toString("utf8"), this.reviver);
72
38
  }
73
- let headerArgs = "";
74
- if (headers) {
75
- const headerArray = [];
76
- headers.forEach((value, key) => {
77
- headerArray.push(`-H '${key}: ${value}'`);
78
- });
79
- headerArgs = headerArray.join(" ");
39
+ };
40
+ var BufferTransport = class {
41
+ contentType = "application/octet-stream";
42
+ serialize(value) {
43
+ return value;
44
+ }
45
+ async deserialize(stream) {
46
+ return await streamToBuffer(stream);
47
+ }
48
+ };
49
+ var StreamTransport = class {
50
+ contentType = "application/octet-stream";
51
+ serialize(value) {
52
+ return value;
53
+ }
54
+ async deserialize(stream) {
55
+ return stream;
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
+ */
61
+ async finalize(payload) {
62
+ const reader = payload.getReader();
63
+ try {
64
+ while (true) {
65
+ const { done } = await reader.read();
66
+ if (done) break;
67
+ }
68
+ } finally {
69
+ reader.releaseLock();
70
+ }
80
71
  }
81
- const bashScript = `
82
- # Wait for any initial boot time
83
- sleep ${initialDelaySeconds}
72
+ };
84
73
 
85
- missed=0
86
- while true; do
87
- # 1) Check if TCP port is listening
88
- if nc -z localhost ${port} 2>/dev/null; then
89
- missed=0
90
- # 2) If port is open, try HTTP POST check
91
- if curl -sSL --fail -o /dev/null -X POST ${headerArgs} "${url}"; then
92
- # Success: port is up AND HTTP returned 2xx (following redirects)
93
- exit 0
94
- fi
95
- else
96
- # Port was closed\u2014increment miss counter
97
- ((missed+=1))
98
- # If closed twice in a row, give up immediately
99
- if [ "$missed" -ge 2 ]; then
100
- exit 1
101
- fi
102
- fi
103
- # Wait before next cycle
104
- sleep ${retryFrequencySeconds}
105
- done
106
- `;
107
- const childProcess = spawn("bash", ["-c", bashScript], {
108
- stdio: "ignore",
109
- detached: true
110
- });
111
- childProcess.unref();
112
- }
74
+ // src/api-client.ts
75
+ import { parseMultipartStream } from "mixpart";
76
+
77
+ // src/dev.ts
78
+ import * as fs from "fs";
79
+ import * as path from "path";
113
80
 
114
81
  // src/types.ts
115
82
  var MessageNotFoundError = class extends Error {
@@ -126,12 +93,6 @@ var MessageNotAvailableError = class extends Error {
126
93
  this.name = "MessageNotAvailableError";
127
94
  }
128
95
  };
129
- var FifoOrderingViolationError = class extends Error {
130
- constructor(messageId, reason) {
131
- super(`FIFO ordering violation for message ${messageId}: ${reason}`);
132
- this.name = "FifoOrderingViolationError";
133
- }
134
- };
135
96
  var MessageCorruptedError = class extends Error {
136
97
  constructor(messageId, reason) {
137
98
  super(`Message ${messageId} is corrupted: ${reason}`);
@@ -147,6 +108,7 @@ var QueueEmptyError = class extends Error {
147
108
  }
148
109
  };
149
110
  var MessageLockedError = class extends Error {
111
+ /** Suggested retry delay in seconds, if provided by the server. */
150
112
  retryAfter;
151
113
  constructor(messageId, retryAfter) {
152
114
  const retryMessage = retryAfter ? ` Retry after ${retryAfter} seconds.` : " Try again later.";
@@ -173,14 +135,6 @@ var BadRequestError = class extends Error {
173
135
  this.name = "BadRequestError";
174
136
  }
175
137
  };
176
- var FailedDependencyError = class extends Error {
177
- constructor(messageId) {
178
- super(
179
- `Failed dependency: FIFO ordering violation for message ${messageId}`
180
- );
181
- this.name = "FailedDependencyError";
182
- }
183
- };
184
138
  var InternalServerError = class extends Error {
185
139
  constructor(message = "Unexpected server error") {
186
140
  super(message);
@@ -193,980 +147,1488 @@ var InvalidLimitError = class extends Error {
193
147
  this.name = "InvalidLimitError";
194
148
  }
195
149
  };
196
- var InvalidCallbackError = class extends Error {
197
- constructor(message) {
198
- super(message);
199
- this.name = "InvalidCallbackError";
150
+ var MessageAlreadyProcessedError = class extends Error {
151
+ constructor(messageId) {
152
+ super(`Message ${messageId} has already been processed`);
153
+ this.name = "MessageAlreadyProcessedError";
200
154
  }
201
155
  };
202
-
203
- // src/client.ts
204
- async function consumeStream(stream) {
205
- const reader = stream.getReader();
206
- try {
207
- while (true) {
208
- const { done } = await reader.read();
209
- if (done) break;
210
- }
211
- } finally {
212
- reader.releaseLock();
156
+ var DuplicateMessageError = class extends Error {
157
+ idempotencyKey;
158
+ constructor(message, idempotencyKey) {
159
+ super(message);
160
+ this.name = "DuplicateMessageError";
161
+ this.idempotencyKey = idempotencyKey;
213
162
  }
214
- }
215
- function parseQueueHeaders(headers) {
216
- const messageId = headers.get("Vqs-Message-Id");
217
- const deliveryCountStr = headers.get("Vqs-Delivery-Count") || "0";
218
- const timestamp = headers.get("Vqs-Timestamp");
219
- const contentType = headers.get("Content-Type") || "application/octet-stream";
220
- const ticket = headers.get("Vqs-Ticket");
221
- if (!messageId || !timestamp || !ticket) {
222
- 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;
223
170
  }
224
- const deliveryCount = parseInt(deliveryCountStr, 10);
225
- if (isNaN(deliveryCount)) {
226
- return null;
171
+ };
172
+ var ConsumerRegistryNotConfiguredError = class extends Error {
173
+ constructor(message = "Consumer registry not configured") {
174
+ super(message);
175
+ this.name = "ConsumerRegistryNotConfiguredError";
227
176
  }
228
- return {
229
- messageId,
230
- deliveryCount,
231
- timestamp,
232
- contentType,
233
- ticket
234
- };
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
+ );
235
190
  }
236
- var QueueClient = class _QueueClient {
237
- baseUrl;
238
- token;
239
- /**
240
- * Internal default instance for use by createTopic and other convenience functions
241
- * @internal
242
- */
243
- static _defaultInstance = null;
244
- /**
245
- * Create a new Vercel Queue Service client
246
- * @param options Client configuration options (optional - will auto-detect Vercel Function environment)
247
- */
248
- constructor(options = {}) {
249
- this.baseUrl = options.baseUrl || "https://vqs.vercel.sh";
250
- if (options.token) {
251
- this.token = options.token;
252
- } else {
253
- const token = this.getVercelOidcTokenSync();
254
- if (!token) {
255
- throw new Error(
256
- "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'"
257
- );
258
- }
259
- this.token = token;
260
- }
261
- }
191
+ var ConsumerGroup = class {
192
+ client;
193
+ topicName;
194
+ consumerGroupName;
195
+ visibilityTimeout;
262
196
  /**
263
- * Get the default client instance for internal use by convenience functions
264
- * @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)
265
204
  */
266
- static _getDefaultInstance() {
267
- if (!this._defaultInstance) {
268
- this._defaultInstance = new _QueueClient();
269
- }
270
- return this._defaultInstance;
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
+ );
271
213
  }
272
214
  /**
273
- * Synchronously get OIDC token from environment
274
- * Used internally by constructor - mirrors the logic from getVercelOidcToken but synchronously
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
275
220
  */
276
- getVercelOidcTokenSync() {
277
- try {
278
- const SYMBOL_FOR_REQ_CONTEXT = Symbol.for("@vercel/request-context");
279
- const fromSymbol = globalThis;
280
- const context = fromSymbol[SYMBOL_FOR_REQ_CONTEXT]?.get?.() ?? {};
281
- const token = context.headers?.["x-vercel-oidc-token"] ?? process.env.VERCEL_OIDC_TOKEN;
282
- return token || null;
283
- } catch {
284
- return null;
285
- }
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;
286
227
  }
287
228
  /**
288
- * Send a message to a queue
289
- * @param options Send message options
290
- * @param transport Serializer/deserializer for the payload
291
- * @returns Promise with the message ID
292
- * @throws {BadRequestError} When request parameters are invalid
293
- * @throws {UnauthorizedError} When authentication fails
294
- * @throws {ForbiddenError} When access is denied (environment mismatch)
295
- * @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.
296
247
  */
297
- async sendMessage(options, transport) {
298
- const { queueName, payload, idempotencyKey, retentionSeconds, callback } = options;
299
- const headers = new Headers({
300
- Authorization: `Bearer ${this.token}`,
301
- "Vqs-Queue-Name": queueName,
302
- "Content-Type": transport.contentType
303
- });
304
- if (idempotencyKey) {
305
- headers.set("Vqs-Idempotency-Key", idempotencyKey);
306
- }
307
- if (retentionSeconds !== void 0) {
308
- headers.set("Vqs-Retention-Seconds", retentionSeconds.toString());
309
- }
310
- let normalizedCallbacks;
311
- if (callback) {
312
- if ("url" in callback && typeof callback.url === "string") {
313
- normalizedCallbacks = { default: callback };
314
- } else {
315
- normalizedCallbacks = callback;
316
- }
317
- }
318
- let localhostCallbacks = [];
319
- if (normalizedCallbacks) {
320
- const isDevelopment = process.env.NODE_ENV === "development";
321
- if (isDevelopment) {
322
- localhostCallbacks = processDevelopmentCallbacks(normalizedCallbacks);
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;
323
260
  } else {
324
- const endpoints = Object.entries(normalizedCallbacks).map(
325
- ([group, config]) => `${group}=${Buffer.from(config.url).toString("base64")}`
326
- ).join(",");
327
- headers.set("Vqs-Callback-Url", endpoints);
328
- const delays = Object.entries(normalizedCallbacks).filter(([, config]) => config.delay !== void 0).map(([group, config]) => `${group}=${config.delay}`).join(",");
329
- if (delays) {
330
- headers.set("Vqs-Callback-Delay", delays);
331
- }
332
- const frequencies = Object.entries(normalizedCallbacks).filter(([, config]) => config.frequency !== void 0).map(([group, config]) => `${group}=${config.frequency}`).join(",");
333
- if (frequencies) {
334
- headers.set("Vqs-Callback-Frequency", frequencies);
335
- }
261
+ firstDelayMs = 0;
336
262
  }
337
263
  }
338
- const body = transport.serialize(payload);
339
- const response = await fetch(`${this.baseUrl}/api/v2/messages`, {
340
- method: "POST",
341
- headers,
342
- body
264
+ const lifecyclePromise = new Promise((resolve) => {
265
+ resolveLifecycle = resolve;
343
266
  });
344
- if (!response.ok) {
345
- if (response.status === 400) {
346
- const errorText = await response.text();
347
- throw new BadRequestError(errorText || "Invalid parameters");
267
+ const safeResolve = () => {
268
+ if (!isResolved) {
269
+ isResolved = true;
270
+ resolveLifecycle();
348
271
  }
349
- if (response.status === 401) {
350
- throw new UnauthorizedError();
272
+ };
273
+ const extend = async () => {
274
+ if (!isRunning) {
275
+ safeResolve();
276
+ return;
351
277
  }
352
- if (response.status === 403) {
353
- throw new ForbiddenError();
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
+ }
354
308
  }
355
- if (response.status === 409) {
356
- throw new Error("Duplicate idempotency key detected");
309
+ };
310
+ timeoutId = setTimeout(() => extend(), firstDelayMs);
311
+ return async (waitForCompletion = false) => {
312
+ isRunning = false;
313
+ if (timeoutId) {
314
+ clearTimeout(timeoutId);
315
+ timeoutId = null;
357
316
  }
358
- if (response.status >= 500) {
359
- throw new InternalServerError(
360
- `Server error: ${response.status} ${response.statusText}`
361
- );
317
+ if (waitForCompletion) {
318
+ await lifecyclePromise;
319
+ } else {
320
+ safeResolve();
362
321
  }
363
- throw new Error(
364
- `Failed to send message: ${response.status} ${response.statusText}`
365
- );
366
- }
367
- const responseData = await response.json();
368
- if (localhostCallbacks.length > 0) {
369
- fireLocalhostCallbacks(localhostCallbacks, queueName, responseData);
370
- }
371
- return responseData;
322
+ };
372
323
  }
373
324
  /**
374
- * Receive messages from a queue
375
- * @param options Receive messages options
376
- * @param transport Serializer/deserializer for the payload
377
- * @returns AsyncGenerator that yields messages as they arrive
378
- * @throws {InvalidLimitError} When limit parameter is not between 1 and 10
379
- * @throws {QueueEmptyError} When no messages are available (204)
380
- * @throws {MessageLockedError} When FIFO queue has locked messages (423)
381
- * @throws {BadRequestError} When request parameters are invalid
382
- * @throws {UnauthorizedError} When authentication fails
383
- * @throws {ForbiddenError} When access is denied (environment mismatch)
384
- * @throws {InternalServerError} When server encounters an error
325
+ * Clean up the message payload if the transport supports it and payload exists.
385
326
  */
386
- async *receiveMessages(options, transport) {
387
- const { queueName, consumerGroup, visibilityTimeoutSeconds, limit } = options;
388
- if (limit !== void 0 && (limit < 1 || limit > 10)) {
389
- throw new InvalidLimitError(limit);
390
- }
391
- const headers = new Headers({
392
- Authorization: `Bearer ${this.token}`,
393
- "Vqs-Queue-Name": queueName,
394
- "Vqs-Consumer-Group": consumerGroup,
395
- Accept: "multipart/mixed"
396
- });
397
- if (visibilityTimeoutSeconds !== void 0) {
398
- headers.set(
399
- "Vqs-Visibility-Timeout",
400
- visibilityTimeoutSeconds.toString()
401
- );
402
- }
403
- if (limit !== void 0) {
404
- headers.set("Vqs-Limit", limit.toString());
405
- }
406
- const response = await fetch(`${this.baseUrl}/api/v2/messages`, {
407
- method: "GET",
408
- headers
409
- });
410
- if (response.status === 204) {
411
- throw new QueueEmptyError(queueName, consumerGroup);
412
- }
413
- if (!response.ok) {
414
- if (response.status === 400) {
415
- const errorText = await response.text();
416
- throw new BadRequestError(errorText || "Invalid parameters");
417
- }
418
- if (response.status === 401) {
419
- throw new UnauthorizedError();
420
- }
421
- if (response.status === 403) {
422
- throw new ForbiddenError();
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);
423
334
  }
424
- if (response.status === 423) {
425
- const retryAfterHeader = response.headers.get("Retry-After");
426
- let retryAfter;
427
- if (retryAfterHeader) {
428
- const parsed = parseInt(retryAfterHeader, 10);
429
- retryAfter = isNaN(parsed) ? void 0 : parsed;
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
+ };
351
+ try {
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
+ }
430
399
  }
431
- throw new MessageLockedError("next message in FIFO queue", retryAfter);
432
- }
433
- if (response.status >= 500) {
434
- throw new InternalServerError(
435
- `Server error: ${response.status} ${response.statusText}`
436
- );
437
400
  }
438
- throw new Error(
439
- `Failed to receive messages: ${response.status} ${response.statusText}`
440
- );
401
+ await this.finalizePayload(message.payload);
402
+ throw error;
441
403
  }
442
- for await (const multipartMessage of parseMultipartStream(response)) {
443
- try {
444
- const parsedHeaders = parseQueueHeaders(multipartMessage.headers);
445
- if (!parsedHeaders) {
446
- console.warn("Missing required queue headers in multipart part");
447
- await consumeStream(multipartMessage.payload);
448
- continue;
449
- }
450
- const deserializedPayload = await transport.deserialize(
451
- multipartMessage.payload
452
- );
453
- const message = {
454
- ...parsedHeaders,
455
- payload: deserializedPayload
456
- };
457
- yield message;
458
- } catch (error) {
459
- console.warn("Failed to process multipart message:", error);
460
- await consumeStream(multipartMessage.payload);
404
+ }
405
+ /**
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.
421
+ */
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 });
461
447
  }
448
+ return messagesProcessed;
462
449
  }
463
450
  }
464
- async receiveMessageById(options, transport) {
465
- const {
466
- queueName,
467
- consumerGroup,
468
- messageId,
469
- visibilityTimeoutSeconds,
470
- skipPayload
471
- } = options;
472
- const headers = new Headers({
473
- Authorization: `Bearer ${this.token}`,
474
- "Vqs-Queue-Name": queueName,
475
- "Vqs-Consumer-Group": consumerGroup,
476
- Accept: "multipart/mixed"
451
+ /**
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
482
+ * @throws {BadRequestError} When request parameters are invalid
483
+ * @throws {UnauthorizedError} When authentication fails
484
+ * @throws {ForbiddenError} When access is denied (environment mismatch)
485
+ * @throws {InternalServerError} When server encounters an error
486
+ */
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
477
495
  });
478
- if (visibilityTimeoutSeconds !== void 0) {
479
- headers.set(
480
- "Vqs-Visibility-Timeout",
481
- visibilityTimeoutSeconds.toString()
496
+ if (result.messageId && isDevMode()) {
497
+ triggerDevCallbacks(
498
+ this.topicName,
499
+ result.messageId,
500
+ this.client.getRegion()
482
501
  );
483
502
  }
484
- if (skipPayload) {
485
- headers.set("Vqs-Skip-Payload", "1");
486
- }
487
- const response = await fetch(
488
- `${this.baseUrl}/api/v2/messages/${encodeURIComponent(messageId)}`,
489
- {
490
- method: "GET",
491
- headers
492
- }
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
493
517
  );
494
- if (!response.ok) {
495
- if (response.status === 400) {
496
- const errorText = await response.text();
497
- throw new BadRequestError(errorText || "Invalid parameters");
498
- }
499
- if (response.status === 401) {
500
- throw new UnauthorizedError();
501
- }
502
- if (response.status === 403) {
503
- throw new ForbiddenError();
504
- }
505
- if (response.status === 404) {
506
- throw new MessageNotFoundError(messageId);
507
- }
508
- if (response.status === 423) {
509
- const retryAfterHeader = response.headers.get("Retry-After");
510
- let retryAfter;
511
- if (retryAfterHeader) {
512
- const parsed = parseInt(retryAfterHeader, 10);
513
- retryAfter = isNaN(parsed) ? void 0 : parsed;
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
+ });
514
743
  }
515
- throw new MessageLockedError(messageId, retryAfter);
516
744
  }
517
- if (response.status === 424) {
518
- throw new FailedDependencyError(messageId);
519
- }
520
- if (response.status === 409) {
521
- throw new MessageNotAvailableError(messageId);
522
- }
523
- if (response.status >= 500) {
524
- throw new InternalServerError(
525
- `Server error: ${response.status} ${response.statusText}`
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
526
792
  );
793
+ continue;
527
794
  }
528
- throw new Error(
529
- `Failed to receive message by ID: ${response.status} ${response.statusText}`
530
- );
531
- }
532
- if (skipPayload && response.status === 204) {
533
- const parsedHeaders = parseQueueHeaders(response.headers);
534
- if (!parsedHeaders) {
535
- throw new MessageCorruptedError(
536
- messageId,
537
- "Missing required queue headers in 204 response"
795
+ if (error instanceof MessageAlreadyProcessedError) {
796
+ console.log(
797
+ `[Dev Mode] Message already processed: topic="${topicName}" messageId="${messageId}"`
538
798
  );
799
+ return false;
539
800
  }
540
- const message = {
541
- ...parsedHeaders,
542
- payload: void 0
543
- };
544
- return { message };
801
+ console.error(
802
+ `[Dev Mode] Error polling for message visibility: topic="${topicName}" messageId="${messageId}"`,
803
+ error
804
+ );
805
+ return false;
545
806
  }
546
- if (!transport) {
547
- throw new Error("Transport is required when skipPayload is not true");
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;
548
850
  }
549
- try {
550
- for await (const multipartMessage of parseMultipartStream(response)) {
551
- try {
552
- const parsedHeaders = parseQueueHeaders(multipartMessage.headers);
553
- if (!parsedHeaders) {
554
- console.warn("Missing required queue headers in multipart part");
555
- await consumeStream(multipartMessage.payload);
556
- continue;
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
+ );
557
892
  }
558
- const deserializedPayload = await transport.deserialize(
559
- multipartMessage.payload
560
- );
561
- const message = {
562
- ...parsedHeaders,
563
- payload: deserializedPayload
564
- };
565
- return { message };
566
- } catch (error) {
567
- console.warn("Failed to deserialize message by ID:", error);
568
- await consumeStream(multipartMessage.payload);
569
- throw new MessageCorruptedError(
570
- messageId,
571
- `Failed to deserialize payload: ${error}`
572
- );
573
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
+ );
574
899
  }
575
- } catch (error) {
576
- if (error instanceof MessageCorruptedError) {
577
- throw error;
578
- }
579
- throw new MessageCorruptedError(
580
- messageId,
581
- `Failed to parse multipart response: ${error}`
582
- );
583
900
  }
584
- throw new MessageNotFoundError(messageId);
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
+ }
585
997
  }
586
998
  /**
587
- * Delete a message (acknowledge processing)
588
- * @param options Delete message options
589
- * @returns Promise with delete status
590
- * @throws {MessageNotFoundError} When the message doesn't exist (404)
591
- * @throws {MessageNotAvailableError} When message can't be deleted (409)
592
- * @throws {BadRequestError} When ticket is missing or invalid (400)
593
- * @throws {UnauthorizedError} When authentication fails
594
- * @throws {ForbiddenError} When access is denied (environment mismatch)
595
- * @throws {InternalServerError} When server encounters an error
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.
596
1003
  */
597
- async deleteMessage(options) {
598
- const { queueName, consumerGroup, messageId, ticket } = options;
599
- const response = await fetch(
600
- `${this.baseUrl}/api/v2/messages/${encodeURIComponent(messageId)}`,
601
- {
602
- method: "DELETE",
603
- headers: new Headers({
604
- Authorization: `Bearer ${this.token}`,
605
- "Vqs-Queue-Name": queueName,
606
- "Vqs-Consumer-Group": consumerGroup,
607
- "Vqs-Ticket": ticket
608
- })
609
- }
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 })'
610
1026
  );
611
- if (!response.ok) {
612
- if (response.status === 400) {
613
- throw new BadRequestError("Missing or invalid ticket");
614
- }
615
- if (response.status === 401) {
616
- throw new UnauthorizedError();
617
- }
618
- if (response.status === 403) {
619
- throw new ForbiddenError();
620
- }
621
- if (response.status === 404) {
622
- throw new MessageNotFoundError(messageId);
623
- }
624
- if (response.status === 409) {
625
- throw new MessageNotAvailableError(
626
- messageId,
627
- "Invalid ticket, message not in correct state, or already processed"
628
- );
629
- }
630
- if (response.status >= 500) {
631
- throw new InternalServerError(
632
- `Server error: ${response.status} ${response.statusText}`
633
- );
634
- }
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) {
635
1048
  throw new Error(
636
- `Failed to delete message: ${response.status} ${response.statusText}`
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'"
637
1050
  );
638
1051
  }
639
- return { deleted: true };
1052
+ return token;
640
1053
  }
641
- /**
642
- * Change the visibility timeout of a message
643
- * @param options Change visibility options
644
- * @returns Promise with update status
645
- * @throws {MessageNotFoundError} When the message doesn't exist (404)
646
- * @throws {MessageNotAvailableError} When message can't be updated (409)
647
- * @throws {BadRequestError} When ticket is missing or visibility timeout invalid (400)
648
- * @throws {UnauthorizedError} When authentication fails
649
- * @throws {ForbiddenError} When access is denied (environment mismatch)
650
- * @throws {InternalServerError} When server encounters an error
651
- */
652
- async changeVisibility(options) {
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
+ }
1079
+ }
1080
+ console.debug("[VQS Debug] Request:", JSON.stringify(logData, null, 2));
1081
+ }
1082
+ init.headers.set("User-Agent", `@vercel/queue/${"0.0.0-alpha.40"}`);
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;
1096
+ }
1097
+ async sendMessage(options) {
1098
+ const transport = this.transport;
653
1099
  const {
654
1100
  queueName,
655
- consumerGroup,
656
- messageId,
657
- ticket,
658
- visibilityTimeoutSeconds
1101
+ payload,
1102
+ idempotencyKey,
1103
+ retentionSeconds,
1104
+ delaySeconds,
1105
+ headers: optionHeaders
659
1106
  } = options;
660
- const response = await fetch(
661
- `${this.baseUrl}/api/v2/messages/${encodeURIComponent(messageId)}`,
662
- {
663
- method: "PATCH",
664
- headers: new Headers({
665
- Authorization: `Bearer ${this.token}`,
666
- "Vqs-Queue-Name": queueName,
667
- "Vqs-Consumer-Group": consumerGroup,
668
- "Vqs-Ticket": ticket,
669
- "Vqs-Visibility-Timeout": visibilityTimeoutSeconds.toString()
670
- })
1107
+ const headers = new Headers();
1108
+ if (this.customHeaders) {
1109
+ for (const [name, value] of Object.entries(this.customHeaders)) {
1110
+ headers.append(name, value);
671
1111
  }
672
- );
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
+ });
673
1148
  if (!response.ok) {
674
- if (response.status === 400) {
675
- throw new BadRequestError(
676
- "Missing ticket or invalid visibility timeout"
1149
+ const errorText = await response.text();
1150
+ if (response.status === 409) {
1151
+ throw new DuplicateMessageError(
1152
+ errorText || "Duplicate idempotency key detected",
1153
+ idempotencyKey
677
1154
  );
678
1155
  }
679
- if (response.status === 401) {
680
- throw new UnauthorizedError();
681
- }
682
- if (response.status === 403) {
683
- throw new ForbiddenError();
684
- }
685
- if (response.status === 404) {
686
- throw new MessageNotFoundError(messageId);
687
- }
688
- if (response.status === 409) {
689
- throw new MessageNotAvailableError(
690
- messageId,
691
- "Invalid ticket, message not in correct state, or already processed"
1156
+ if (response.status === 502) {
1157
+ throw new ConsumerDiscoveryError(
1158
+ errorText || "Consumer discovery failed",
1159
+ deploymentId
692
1160
  );
693
1161
  }
694
- if (response.status >= 500) {
695
- throw new InternalServerError(
696
- `Server error: ${response.status} ${response.statusText}`
1162
+ if (response.status === 503) {
1163
+ throw new ConsumerRegistryNotConfiguredError(
1164
+ errorText || "Consumer registry not configured"
697
1165
  );
698
1166
  }
699
- throw new Error(
700
- `Failed to change visibility: ${response.status} ${response.statusText}`
1167
+ throwCommonHttpError(
1168
+ response.status,
1169
+ response.statusText,
1170
+ errorText,
1171
+ "send message"
701
1172
  );
702
1173
  }
703
- return { updated: true };
704
- }
705
- };
706
-
707
- // src/transports.ts
708
- var JsonTransport = class {
709
- contentType = "application/json";
710
- serialize(value) {
711
- return Buffer.from(JSON.stringify(value), "utf8");
1174
+ if (response.status === 202) {
1175
+ return { messageId: null };
1176
+ }
1177
+ const responseData = await response.json();
1178
+ return responseData;
712
1179
  }
713
- async deserialize(stream) {
714
- const reader = stream.getReader();
715
- const chunks = [];
716
- try {
717
- while (true) {
718
- const { done, value } = await reader.read();
719
- if (done) break;
720
- chunks.push(value);
721
- }
722
- } finally {
723
- reader.releaseLock();
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);
724
1185
  }
725
- const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
726
- const buffer = new Uint8Array(totalLength);
727
- let offset = 0;
728
- for (const chunk of chunks) {
729
- buffer.set(chunk, offset);
730
- offset += chunk.length;
1186
+ const headers = new Headers({
1187
+ Authorization: `Bearer ${await this.getToken()}`,
1188
+ Accept: "multipart/mixed",
1189
+ ...this.customHeaders
1190
+ });
1191
+ if (visibilityTimeoutSeconds !== void 0) {
1192
+ headers.set(
1193
+ "Vqs-Visibility-Timeout-Seconds",
1194
+ visibilityTimeoutSeconds.toString()
1195
+ );
731
1196
  }
732
- return JSON.parse(Buffer.from(buffer).toString("utf8"));
733
- }
734
- };
735
- var BufferTransport = class {
736
- contentType = "application/octet-stream";
737
- serialize(value) {
738
- return value;
739
- }
740
- async deserialize(stream) {
741
- const reader = stream.getReader();
742
- const chunks = [];
743
- try {
744
- while (true) {
745
- const { done, value } = await reader.read();
746
- if (done) break;
747
- chunks.push(value);
748
- }
749
- } finally {
750
- reader.releaseLock();
1197
+ if (limit !== void 0) {
1198
+ headers.set("Vqs-Max-Messages", limit.toString());
751
1199
  }
752
- const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
753
- const buffer = new Uint8Array(totalLength);
754
- let offset = 0;
755
- for (const chunk of chunks) {
756
- buffer.set(chunk, offset);
757
- offset += chunk.length;
1200
+ const effectiveDeploymentId = this.getConsumeDeploymentId();
1201
+ if (effectiveDeploymentId) {
1202
+ headers.set("Vqs-Deployment-Id", effectiveDeploymentId);
758
1203
  }
759
- return Buffer.from(buffer);
760
- }
761
- };
762
- var StreamTransport = class {
763
- contentType = "application/octet-stream";
764
- serialize(value) {
765
- return value;
766
- }
767
- async deserialize(stream) {
768
- return stream;
769
- }
770
- async finalize(payload) {
771
- const reader = payload.getReader();
772
- try {
773
- while (true) {
774
- const { done } = await reader.read();
775
- if (done) break;
1204
+ const response = await this.fetch(
1205
+ this.buildUrl(queueName, "consumer", consumerGroup),
1206
+ {
1207
+ method: "POST",
1208
+ headers
776
1209
  }
777
- } finally {
778
- reader.releaseLock();
1210
+ );
1211
+ if (response.status === 204) {
1212
+ return;
779
1213
  }
780
- }
781
- };
782
-
783
- // src/consumer-group.ts
784
- var ConsumerGroup = class {
785
- client;
786
- topicName;
787
- consumerGroupName;
788
- visibilityTimeout;
789
- refreshInterval;
790
- transport;
791
- /**
792
- * Create a new ConsumerGroup instance
793
- * @param client QueueClient instance to use for API calls
794
- * @param topicName Name of the topic to consume from
795
- * @param consumerGroupName Name of the consumer group
796
- * @param options Optional configuration
797
- */
798
- constructor(client, topicName, consumerGroupName, options = {}) {
799
- this.client = client;
800
- this.topicName = topicName;
801
- this.consumerGroupName = consumerGroupName;
802
- this.visibilityTimeout = options.visibilityTimeoutSeconds || 30;
803
- this.refreshInterval = options.refreshInterval || 10;
804
- this.transport = options.transport || new JsonTransport();
805
- }
806
- /**
807
- * Starts a background loop that periodically extends the visibility timeout for a message.
808
- * This prevents the message from becoming visible to other consumers while it's being processed.
809
- *
810
- * The extension loop runs every `refreshInterval` seconds and updates the message's
811
- * visibility timeout to `visibilityTimeout` seconds from the current time.
812
- *
813
- * @param messageId - The unique identifier of the message to extend visibility for
814
- * @param ticket - The receipt ticket that proves ownership of the message
815
- * @returns A function that when called will stop the extension loop
816
- *
817
- * @remarks
818
- * - The first extension attempt occurs after `refreshInterval` seconds, not immediately
819
- * - If an extension fails, the loop terminates with an error logged to console
820
- * - The returned stop function is idempotent - calling it multiple times is safe
821
- * - By default, the stop function returns immediately without waiting for in-flight
822
- * - Pass `true` to the stop function to wait for any in-flight extension to complete
823
- */
824
- startVisibilityExtension(messageId, ticket) {
825
- let isRunning = true;
826
- let resolveLifecycle;
827
- let timeoutId = null;
828
- const lifecyclePromise = new Promise((resolve) => {
829
- resolveLifecycle = resolve;
830
- });
831
- const extend = async () => {
832
- if (!isRunning) {
833
- resolveLifecycle();
834
- return;
835
- }
1214
+ if (!response.ok) {
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)) {
836
1224
  try {
837
- await this.client.changeVisibility({
838
- queueName: this.topicName,
839
- consumerGroup: this.consumerGroupName,
840
- messageId,
841
- ticket,
842
- visibilityTimeoutSeconds: this.visibilityTimeout
843
- });
844
- if (isRunning) {
845
- timeoutId = setTimeout(() => extend(), this.refreshInterval * 1e3);
846
- } else {
847
- resolveLifecycle();
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;
848
1230
  }
849
- } catch (error) {
850
- console.error(
851
- `Failed to extend visibility for message ${messageId}:`,
852
- error
1231
+ const deserializedPayload = await transport.deserialize(
1232
+ multipartMessage.payload
853
1233
  );
854
- resolveLifecycle();
855
- }
856
- };
857
- timeoutId = setTimeout(() => extend(), this.refreshInterval * 1e3);
858
- return async (waitForCompletion = false) => {
859
- isRunning = false;
860
- if (timeoutId) {
861
- clearTimeout(timeoutId);
862
- timeoutId = null;
863
- }
864
- if (waitForCompletion) {
865
- await lifecyclePromise;
866
- } else {
867
- resolveLifecycle();
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);
868
1242
  }
869
- };
1243
+ }
870
1244
  }
871
- /**
872
- * Process a single message with the given handler
873
- * @param message The message to process
874
- * @param handler Function to process the message
875
- */
876
- async processMessage(message, handler) {
877
- const stopExtension = this.startVisibilityExtension(
878
- message.messageId,
879
- message.ticket
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
1268
+ }
880
1269
  );
881
- try {
882
- const result = await handler(message.payload, {
883
- messageId: message.messageId,
884
- deliveryCount: message.deliveryCount,
885
- timestamp: message.timestamp
886
- });
887
- await stopExtension();
888
- if (result && "timeoutSeconds" in result) {
889
- await this.client.changeVisibility({
890
- queueName: this.topicName,
891
- consumerGroup: this.consumerGroupName,
892
- messageId: message.messageId,
893
- ticket: message.ticket,
894
- visibilityTimeoutSeconds: result.timeoutSeconds
895
- });
896
- } else {
897
- await this.client.deleteMessage({
898
- queueName: this.topicName,
899
- consumerGroup: this.consumerGroupName,
900
- messageId: message.messageId,
901
- ticket: message.ticket
902
- });
1270
+ if (!response.ok) {
1271
+ const errorText = await response.text();
1272
+ if (response.status === 404) {
1273
+ throw new MessageNotFoundError(messageId);
903
1274
  }
904
- } catch (error) {
905
- await stopExtension();
906
- if (this.transport.finalize && message.payload !== void 0 && message.payload !== null) {
1275
+ if (response.status === 409) {
1276
+ let errorData = {};
907
1277
  try {
908
- await this.transport.finalize(message.payload);
909
- } catch (finalizeError) {
910
- console.warn("Failed to finalize message payload:", finalizeError);
1278
+ errorData = JSON.parse(errorText);
1279
+ } catch {
911
1280
  }
1281
+ if (errorData.originalMessageId) {
1282
+ throw new MessageNotAvailableError(
1283
+ messageId,
1284
+ `This message was a duplicate - use originalMessageId: ${errorData.originalMessageId}`
1285
+ );
1286
+ }
1287
+ throw new MessageNotAvailableError(messageId);
912
1288
  }
913
- throw error;
1289
+ if (response.status === 410) {
1290
+ throw new MessageAlreadyProcessedError(messageId);
1291
+ }
1292
+ throwCommonHttpError(
1293
+ response.status,
1294
+ response.statusText,
1295
+ errorText,
1296
+ "receive message by ID"
1297
+ );
914
1298
  }
915
- }
916
- async consume(handler, options) {
917
- if (options?.messageId) {
918
- if (options.skipPayload) {
919
- const response = await this.client.receiveMessageById(
920
- {
921
- queueName: this.topicName,
922
- consumerGroup: this.consumerGroupName,
923
- messageId: options.messageId,
924
- visibilityTimeoutSeconds: this.visibilityTimeout,
925
- skipPayload: true
926
- },
927
- this.transport
928
- );
929
- await this.processMessage(
930
- response.message,
931
- handler
932
- );
933
- } else {
934
- const response = await this.client.receiveMessageById(
935
- {
936
- queueName: this.topicName,
937
- consumerGroup: this.consumerGroupName,
938
- messageId: options.messageId,
939
- visibilityTimeoutSeconds: this.visibilityTimeout
940
- },
941
- this.transport
942
- );
943
- await this.processMessage(
944
- response.message,
945
- handler
1299
+ for await (const multipartMessage of parseMultipartStream(response)) {
1300
+ const parsedHeaders = parseQueueHeaders(multipartMessage.headers);
1301
+ if (!parsedHeaders) {
1302
+ await consumeStream(multipartMessage.payload);
1303
+ throw new MessageCorruptedError(
1304
+ messageId,
1305
+ "Missing required queue headers in response"
946
1306
  );
947
1307
  }
948
- } else {
949
- let messageFound = false;
950
- for await (const message of this.client.receiveMessages(
951
- {
952
- queueName: this.topicName,
953
- consumerGroup: this.consumerGroupName,
954
- visibilityTimeoutSeconds: this.visibilityTimeout,
955
- limit: 1
956
- },
957
- this.transport
958
- )) {
959
- messageFound = true;
960
- await this.processMessage(message, handler);
961
- break;
1308
+ const deserializedPayload = await transport.deserialize(
1309
+ multipartMessage.payload
1310
+ );
1311
+ const message = {
1312
+ ...parsedHeaders,
1313
+ payload: deserializedPayload
1314
+ };
1315
+ return { message };
1316
+ }
1317
+ throw new MessageNotFoundError(messageId);
1318
+ }
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
+ ),
1337
+ {
1338
+ method: "DELETE",
1339
+ headers
1340
+ }
1341
+ );
1342
+ if (!response.ok) {
1343
+ const errorText = await response.text();
1344
+ if (response.status === 404) {
1345
+ throw new MessageNotFoundError(receiptHandle);
962
1346
  }
963
- if (!messageFound) {
964
- throw new Error("No messages available");
1347
+ if (response.status === 409) {
1348
+ throw new MessageNotAvailableError(
1349
+ receiptHandle,
1350
+ errorText || "Invalid receipt handle, message not in correct state, or already processed"
1351
+ );
965
1352
  }
1353
+ throwCommonHttpError(
1354
+ response.status,
1355
+ response.statusText,
1356
+ errorText,
1357
+ "acknowledge message",
1358
+ "Missing or invalid receipt handle"
1359
+ );
966
1360
  }
1361
+ return { acknowledged: true };
967
1362
  }
968
- /**
969
- * Get the consumer group name
970
- */
971
- get name() {
972
- return this.consumerGroupName;
973
- }
974
- /**
975
- * Get the topic name this consumer group is subscribed to
976
- */
977
- get topic() {
978
- return this.topicName;
979
- }
980
- };
981
-
982
- // src/topic.ts
983
- var Topic = class {
984
- client;
985
- topicName;
986
- transport;
987
- /**
988
- * Create a new Topic instance
989
- * @param client QueueClient instance to use for API calls
990
- * @param topicName Name of the topic to work with
991
- * @param transport Optional serializer/deserializer for the payload (defaults to JSON)
992
- */
993
- constructor(client, topicName, transport) {
994
- this.client = client;
995
- this.topicName = topicName;
996
- this.transport = transport || new JsonTransport();
997
- }
998
- /**
999
- * Publish a message to the topic
1000
- * @param payload The data to publish
1001
- * @param options Optional publish options
1002
- * @returns An object containing the message ID
1003
- * @throws {BadRequestError} When request parameters are invalid
1004
- * @throws {UnauthorizedError} When authentication fails
1005
- * @throws {ForbiddenError} When access is denied (environment mismatch)
1006
- * @throws {InternalServerError} When server encounters an error
1007
- */
1008
- async publish(payload, options) {
1009
- const result = await this.client.sendMessage(
1363
+ async changeVisibility(options) {
1364
+ const {
1365
+ queueName,
1366
+ consumerGroup,
1367
+ receiptHandle,
1368
+ visibilityTimeoutSeconds
1369
+ } = options;
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
+ ),
1010
1387
  {
1011
- queueName: this.topicName,
1012
- payload,
1013
- idempotencyKey: options?.idempotencyKey,
1014
- retentionSeconds: options?.retentionSeconds,
1015
- callback: options?.callback
1016
- },
1017
- this.transport
1388
+ method: "PATCH",
1389
+ headers,
1390
+ body: JSON.stringify({ visibilityTimeoutSeconds })
1391
+ }
1018
1392
  );
1019
- return { messageId: result.messageId };
1393
+ if (!response.ok) {
1394
+ const errorText = await response.text();
1395
+ if (response.status === 404) {
1396
+ throw new MessageNotFoundError(receiptHandle);
1397
+ }
1398
+ if (response.status === 409) {
1399
+ throw new MessageNotAvailableError(
1400
+ receiptHandle,
1401
+ errorText || "Invalid receipt handle, message not in correct state, or already processed"
1402
+ );
1403
+ }
1404
+ throwCommonHttpError(
1405
+ response.status,
1406
+ response.statusText,
1407
+ errorText,
1408
+ "change visibility",
1409
+ "Missing receipt handle or invalid visibility timeout"
1410
+ );
1411
+ }
1412
+ return { success: true };
1020
1413
  }
1021
- /**
1022
- * Create a consumer group for this topic
1023
- * @param consumerGroupName Name of the consumer group
1024
- * @param options Optional configuration for the consumer group
1025
- * @returns A ConsumerGroup instance
1026
- */
1027
- consumerGroup(consumerGroupName, options) {
1028
- const consumerOptions = {
1029
- ...options,
1030
- transport: options?.transport || this.transport
1031
- };
1032
- return new ConsumerGroup(
1033
- this.client,
1034
- this.topicName,
1035
- consumerGroupName,
1036
- consumerOptions
1037
- );
1414
+ };
1415
+
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");
1038
1422
  }
1039
- /**
1040
- * Get the topic name
1041
- */
1042
- get name() {
1043
- return this.topicName;
1423
+ return api;
1424
+ }
1425
+ var QueueClient = class {
1426
+ constructor(options) {
1427
+ apiClients.set(this, new ApiClient(options));
1044
1428
  }
1045
1429
  /**
1046
- * Get the transport used by this topic
1430
+ * Send a message to a topic.
1431
+ *
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
+ * ```
1437
+ *
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)
1047
1443
  */
1048
- get serializer() {
1049
- return this.transport;
1050
- }
1051
- };
1052
-
1053
- // src/factory.ts
1054
- function createTopic(topicName, transport) {
1055
- const client = QueueClient._getDefaultInstance();
1056
- return new Topic(client, topicName, transport);
1057
- }
1058
- async function send(topicName, payload, options) {
1059
- const transport = options?.transport || new JsonTransport();
1060
- const client = QueueClient._getDefaultInstance();
1061
- const result = await client.sendMessage(
1062
- {
1444
+ send = async (topicName, payload, options) => {
1445
+ const api = getApiClient(this);
1446
+ const result = await api.sendMessage({
1063
1447
  queueName: topicName,
1064
1448
  payload,
1065
1449
  idempotencyKey: options?.idempotencyKey,
1066
1450
  retentionSeconds: options?.retentionSeconds,
1067
- callback: options?.callback
1068
- },
1069
- transport
1070
- );
1071
- return { messageId: result.messageId };
1072
- }
1073
- async function receive(topicName, consumerGroup, handler, options) {
1074
- const transport = options?.transport || new JsonTransport();
1075
- const topic = createTopic(topicName, transport);
1076
- const { messageId, skipPayload, ...consumerGroupOptions } = options || {};
1077
- const consumer = topic.consumerGroup(consumerGroup, consumerGroupOptions);
1078
- if (messageId) {
1079
- if (skipPayload) {
1080
- return consumer.consume(handler, {
1081
- messageId,
1082
- skipPayload: true
1083
- });
1084
- } else {
1085
- return consumer.consume(handler, { messageId });
1451
+ delaySeconds: options?.delaySeconds,
1452
+ headers: options?.headers
1453
+ });
1454
+ if (result.messageId && isDevMode()) {
1455
+ triggerDevCallbacks(
1456
+ topicName,
1457
+ result.messageId,
1458
+ api.getRegion(),
1459
+ options?.delaySeconds
1460
+ );
1086
1461
  }
1087
- } else {
1088
- return consumer.consume(handler);
1089
- }
1090
- }
1091
-
1092
- // src/callback.ts
1093
- function parseCallbackRequest(request) {
1094
- const headers = request.headers;
1095
- const messageId = headers.get("Vqs-Message-Id");
1096
- const queueName = headers.get("Vqs-Queue-Name");
1097
- const consumerGroup = headers.get("Vqs-Consumer-Group");
1098
- const missingHeaders = [];
1099
- if (!messageId) missingHeaders.push("Vqs-Message-Id");
1100
- if (!queueName) missingHeaders.push("Vqs-Queue-Name");
1101
- if (!consumerGroup) missingHeaders.push("Vqs-Consumer-Group");
1102
- if (missingHeaders.length > 0) {
1103
- throw new InvalidCallbackError(
1104
- `Missing required queue callback headers: ${missingHeaders.join(", ")}`
1105
- );
1106
- }
1107
- return {
1108
- messageId,
1109
- queueName,
1110
- consumerGroup
1462
+ return { messageId: result.messageId };
1111
1463
  };
1112
- }
1113
- function handleCallback(handlers) {
1114
- return async (request) => {
1464
+ /**
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
1484
+ */
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 } : {}
1492
+ );
1115
1493
  try {
1116
- const { queueName, consumerGroup, messageId } = parseCallbackRequest(request);
1117
- const topicHandler = handlers[queueName];
1118
- if (!topicHandler) {
1119
- throw new Error(`No handler found for topic: ${queueName}`);
1120
- }
1121
- let actualHandler;
1122
- if (typeof topicHandler === "function") {
1123
- if (consumerGroup !== "default") {
1124
- throw new Error(
1125
- `Topic "${queueName}" has a single handler but received consumer group "${consumerGroup}". Expected "default".`
1126
- );
1127
- }
1128
- actualHandler = topicHandler;
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
1500
+ });
1129
1501
  } else {
1130
- const consumerGroupHandler = topicHandler[consumerGroup];
1131
- if (!consumerGroupHandler) {
1132
- const availableGroups = Object.keys(topicHandler).join(", ");
1133
- throw new Error(
1134
- `No handler found for consumer group "${consumerGroup}" in topic "${queueName}". Available groups: ${availableGroups}`
1135
- );
1136
- }
1137
- actualHandler = consumerGroupHandler;
1502
+ const limit = options && "limit" in options ? options.limit : void 0;
1503
+ count = await consumer.consume(handler, {
1504
+ ...limit !== void 0 ? { limit } : {},
1505
+ retry
1506
+ });
1507
+ }
1508
+ if (count === 0) {
1509
+ return { ok: false, reason: "empty" };
1138
1510
  }
1139
- const client = new QueueClient();
1140
- const topic = new Topic(client, queueName);
1141
- const cg = topic.consumerGroup(consumerGroup);
1142
- await cg.consume(actualHandler, { messageId });
1143
- return Response.json({ status: "success" });
1511
+ return { ok: true };
1144
1512
  } catch (error) {
1145
- console.error("Callback error:", error);
1146
- if (error instanceof InvalidCallbackError) {
1513
+ if (options && "messageId" in options && error instanceof MessageNotFoundError) {
1514
+ return { ok: false, reason: "not_found", messageId: options.messageId };
1515
+ }
1516
+ if (options && "messageId" in options && error instanceof MessageNotAvailableError) {
1517
+ return {
1518
+ ok: false,
1519
+ reason: "not_available",
1520
+ messageId: options.messageId
1521
+ };
1522
+ }
1523
+ if (options && "messageId" in options && error instanceof MessageAlreadyProcessedError) {
1524
+ return {
1525
+ ok: false,
1526
+ reason: "already_processed",
1527
+ messageId: options.messageId
1528
+ };
1529
+ }
1530
+ throw error;
1531
+ }
1532
+ };
1533
+ /**
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
1551
+ */
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
+ }
1147
1567
  return Response.json(
1148
- { error: "Invalid callback request" },
1149
- { status: 400 }
1568
+ { error: "Failed to process queue message" },
1569
+ { status: 500 }
1150
1570
  );
1151
1571
  }
1152
- return Response.json(
1153
- { error: "Failed to process callback" },
1154
- { status: 500 }
1155
- );
1156
- }
1572
+ };
1157
1573
  };
1158
- }
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;
1599
+ }
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" });
1615
+ }
1616
+ };
1617
+ };
1618
+ };
1159
1619
  export {
1160
1620
  BadRequestError,
1161
1621
  BufferTransport,
1162
- ConsumerGroup,
1163
- FailedDependencyError,
1164
- FifoOrderingViolationError,
1622
+ CLOUD_EVENT_TYPE_V1BETA,
1623
+ CLOUD_EVENT_TYPE_V2BETA,
1624
+ ConsumerDiscoveryError,
1625
+ ConsumerRegistryNotConfiguredError,
1626
+ DuplicateMessageError,
1165
1627
  ForbiddenError,
1166
1628
  InternalServerError,
1167
- InvalidCallbackError,
1168
1629
  InvalidLimitError,
1169
1630
  JsonTransport,
1631
+ MessageAlreadyProcessedError,
1170
1632
  MessageCorruptedError,
1171
1633
  MessageLockedError,
1172
1634
  MessageNotAvailableError,
@@ -1174,12 +1636,8 @@ export {
1174
1636
  QueueClient,
1175
1637
  QueueEmptyError,
1176
1638
  StreamTransport,
1177
- Topic,
1178
1639
  UnauthorizedError,
1179
- createTopic,
1180
- handleCallback,
1181
- parseCallbackRequest,
1182
- receive,
1183
- send
1640
+ parseCallback,
1641
+ parseRawCallback
1184
1642
  };
1185
1643
  //# sourceMappingURL=index.mjs.map