@fgiova/sqs-consumer 2.2.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,25 @@ const consumer = new SQSConsumer({
29
29
 
30
30
  ```
31
31
 
32
+ ## Handler cancellation on timeout
33
+
34
+ When `handlerOptions.executionTimeout` triggers, the consumer aborts the in-flight handler via an `AbortSignal` exposed through `AsyncLocalStorage`. The handler does not need to receive the signal as an argument — it is retrieved on demand using the `getAbortSignal` helper. Pass it to any cancellable async API (`fetch`, `undici`, timers, streams) to release resources promptly and avoid zombie handlers retaining memory after a timeout.
35
+
36
+ ```typescript
37
+ import { SQSConsumer, getAbortSignal } from '@fgiova/sqs-consumer'
38
+
39
+ const consumer = new SQSConsumer({
40
+ queueARN: "arn:aws:sqs:eu-central-1:000000000000:test-queue-hooks",
41
+ handler: async (message) => {
42
+ const signal = getAbortSignal();
43
+ const res = await fetch("https://api.example.com/work", { signal });
44
+ return res.json();
45
+ }
46
+ });
47
+ ```
48
+
49
+ `getAbortSignal()` returns `undefined` when called outside a handler execution context. Handlers that ignore it keep working unchanged.
50
+
32
51
  ## Options
33
52
  | Option | Type | Default | Description |
34
53
  |-----------------|-------------------------------------|---------|----------------------------------------------------|
@@ -39,15 +58,15 @@ const consumer = new SQSConsumer({
39
58
  | handlerOptions | HandlerOptions | | The options for the handler |
40
59
  | clientOptions | ClientOptions | | The options for the client |
41
60
  | consumerOptions | ConsumerOptions | | The options for the consumer |
42
- | hooks | Hooks | | The hooks to be called on specific events |
61
+ | hooks | HooksOptions | | The hooks to be called on specific events |
43
62
 
44
63
  ### HandlerOptions
45
- | Option | Type | Default | Description |
46
- |-------------------|---------|---------|----------------------------------------------------------------------------------------------------------|
47
- | deleteMessage | boolean | true | Whether to delete the message after handling (if handler execute without any error) |
48
- | extendVisibility | boolean | true | Whether to extend the visibility timeout during message handling |
49
- | excuteTimeout | number | 30000 | The timeout for the handler execution in ms |
50
- | parallelExecution | boolean | true | If true execute handler in parallel for each batch of messages received, otherwise execute consecutively |
64
+ | Option | Type | Default | Description |
65
+ |-------------------------|---------|---------|----------------------------------------------------------------------------------------------------------|
66
+ | deleteMessage | boolean | true | Whether to delete the message after handling (if handler execute without any error) |
67
+ | extendVisibilityTimeout | boolean | true | Whether to extend the visibility timeout during message handling |
68
+ | executionTimeout | number | 30000 | The timeout for the handler execution in ms |
69
+ | parallelExecution | boolean | true | If true execute handler in parallel for each batch of messages received, otherwise execute consecutively |
51
70
 
52
71
  ### ClientOptions
53
72
  | Option | Type | Default | Description |
@@ -56,7 +75,7 @@ const consumer = new SQSConsumer({
56
75
  | endpoint | string | | The endpoint to be used for the client. If not provided, the endpoint will be inferred from the queueARN. |
57
76
  | undiciOptions | Pool.Options | | The options for the undici client. |
58
77
  | signer | Signer / SignerOptions | | The signer to be used for signing requests. If not provided, a new singleton signer will be created. |
59
- | destroySigner | boolean | false | Whether to destroy the signer when the consumer is destroyed. |
78
+ | destroySigner | boolean | true | Whether to destroy the signer when the consumer is destroyed. |
60
79
 
61
80
  ### ConsumerOptions
62
81
  | Option | Type | Default | Description |
@@ -68,17 +87,16 @@ const consumer = new SQSConsumer({
68
87
  | attributeNames | string[] | [] | The attribute names to be included in the response |
69
88
 
70
89
  ### Hooks
71
- | Option | Type | Description |
72
- |------------------|-----------------------------------------------------------------------|----------------------------------------------------------------------|
73
- | onPoll | (messages: Message[]) => Promise<Message[]> | Called when the consumer polls for messages |
74
- | onMessage | (message: Message) => Promise<Message> | Called when the consumer receives a message |
75
- | onHandle | (message: Message) => Promise<Message> | Called when the consumer handles a message |
76
- | onHandlerSuccess | (message: Message) => Promise<Message> | Called when the consumer handles a message successfully |
77
- | onHandlerTimeout | (message: Message) => Promise<Message> | Called when the consumer handler execution exceed executionTimeout |
78
- | onHandlerError | (message: Message, error: Error) => Promise<Boolean> | Called when the consumer handler execution throws an error |
79
- | onSuccess | (message: Message) => Promise<Message> | Called when the consumer handler execution finishes successfully |
80
- | onError | ( hook: HookName, message: Message, error: Error) => Promise<Boolean> | Called when the consumer handler execution throws an uncaught error |
81
- | onSQSError | (error: Error, message?: Message) => Promise<void> | Called when the consumer receives an error from the SQS service |
90
+ | Option | Type | Description |
91
+ |------------------|-----------------------------------------------------------------------|---------------------------------------------------------------------|
92
+ | onPoll | (messages: Message[]) => Promise<Message[]> | Called when the consumer polls for messages |
93
+ | onMessage | (message: Message) => Promise<Message> | Called when the consumer receives a message |
94
+ | onHandlerSuccess | (message: Message) => Promise<Message> | Called when the consumer handles a message successfully |
95
+ | onHandlerTimeout | (message: Message) => Promise<boolean> | Called when the consumer handler execution exceed executionTimeout |
96
+ | onHandlerError | (message: Message, error: Error) => Promise<boolean> | Called when the consumer handler execution throws an error |
97
+ | onSuccess | (message: Message) => Promise<boolean> | Called when the consumer handler execution finishes successfully |
98
+ | onError | (hook: HookName, message: Message, error: Error) => Promise<boolean> | Called when the consumer handler execution throws an uncaught error |
99
+ | onSQSError | (error: Error, message?: Message) => Promise<void> | Called when the consumer receives an error from the SQS service |
82
100
 
83
101
  ## API
84
102
 
@@ -86,7 +104,8 @@ const consumer = new SQSConsumer({
86
104
  SQSConsumer(options: SQSConsumerOptions)
87
105
  SQSConsumer.start(): Promise<void>
88
106
  SQSConsumer.stop(destroyConsumer = false): Promise<void>
89
- SQSConsumer.addHook(hookname: string, func: Function): void
107
+ SQSConsumer.addHook<H extends HookName>(hookName: H, value: HookCallback<H>): this
108
+ getAbortSignal(): AbortSignal | undefined
90
109
  ```
91
110
 
92
111
  ## License
@@ -0,0 +1,7 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ type HandlerContext = {
3
+ signal: AbortSignal;
4
+ };
5
+ export declare const handlerContext: AsyncLocalStorage<HandlerContext>;
6
+ export declare function getAbortSignal(): AbortSignal | undefined;
7
+ export {};
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handlerContext = void 0;
4
+ exports.getAbortSignal = getAbortSignal;
5
+ const node_async_hooks_1 = require("node:async_hooks");
6
+ exports.handlerContext = new node_async_hooks_1.AsyncLocalStorage();
7
+ function getAbortSignal() {
8
+ return exports.handlerContext.getStore()?.signal;
9
+ }
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TimeoutError = void 0;
4
4
  class TimeoutError extends Error {
5
5
  constructor(message) {
6
- /* c8 ignore next 1 */
7
6
  super(message || "Timeout Error");
8
7
  this.name = "TimeoutError";
9
8
  }
@@ -2,13 +2,56 @@
2
2
  import type { Message } from "@fgiova/mini-sqs-client";
3
3
  import type { Logger } from "./logger";
4
4
  export type HookName = "onStart" | "onStop" | "onPoll" | "onMessage" | "onHandlerSuccess" | "onHandlerTimeout" | "onHandlerError" | "onSuccess" | "onError" | "onSQSError";
5
+ export type HookSignatureMap = {
6
+ onStart: {
7
+ args: [sqsConsumer: unknown];
8
+ return: undefined;
9
+ };
10
+ onStop: {
11
+ args: [sqsConsumer: unknown];
12
+ return: undefined;
13
+ };
14
+ onPoll: {
15
+ args: [messages: Message[]];
16
+ return: Message[];
17
+ };
18
+ onMessage: {
19
+ args: [message: Message];
20
+ return: Message;
21
+ };
22
+ onHandlerSuccess: {
23
+ args: [message: Message];
24
+ return: Message;
25
+ };
26
+ onHandlerTimeout: {
27
+ args: [message: Message];
28
+ return: boolean;
29
+ };
30
+ onHandlerError: {
31
+ args: [message: Message, error: Error];
32
+ return: boolean;
33
+ };
34
+ onSuccess: {
35
+ args: [message: Message];
36
+ return: boolean;
37
+ };
38
+ onError: {
39
+ args: [hook: HookName, message: Message, error: Error];
40
+ return: boolean;
41
+ };
42
+ onSQSError: {
43
+ args: [error: Error, message?: Message];
44
+ return: undefined;
45
+ };
46
+ };
47
+ export type HookCallback<H extends HookName> = (...args: HookSignatureMap[H]["args"]) => HookSignatureMap[H]["return"] | Promise<HookSignatureMap[H]["return"]> | void | Promise<void> | undefined;
5
48
  export declare class Hooks {
6
49
  private readonly hookSymbols;
7
50
  private readonly hooks;
8
51
  private readonly logger;
9
52
  constructor(logger?: Logger);
10
- addHook(hook: HookName, fn: Function): void;
11
- runHook(hook: HookName, ...args: any[]): Promise<boolean | void | Message | Message[]>;
53
+ addHook<H extends HookName>(hook: H, fn: HookCallback<H>): void;
54
+ runHook<H extends HookName>(hook: H, ...args: HookSignatureMap[H]["args"]): Promise<HookSignatureMap[H]["return"]>;
12
55
  private runHookWithVoidReturn;
13
56
  private runHookWithMessageReturn;
14
57
  private runHookWithBooleanReturn;
package/dist/cjs/hooks.js CHANGED
@@ -75,31 +75,36 @@ class Hooks {
75
75
  throw new Error(`Invalid hook ${hook}`);
76
76
  this.hooks[hookSymbol.S]?.push(fn);
77
77
  }
78
- // biome-ignore lint/suspicious/noExplicitAny: args can be anything
79
78
  async runHook(hook, ...args) {
80
79
  const hookSymbol = this.hookSymbols[hook];
80
+ // biome-ignore lint/suspicious/noExplicitAny: internal cast for generic dispatch
81
+ const hookArgs = args;
81
82
  try {
82
83
  if (!hookSymbol)
83
84
  throw new Error(`Invalid hook ${hook}`);
84
85
  switch (hookSymbol.type) {
85
86
  case "boolean-return":
86
- return await this.runHookWithBooleanReturn(hookSymbol, ...args);
87
+ return (await this.runHookWithBooleanReturn(hookSymbol, ...hookArgs));
87
88
  case "message-return": {
88
- const [message, ...someArgs] = args;
89
- return await this.runHookWithMessageReturn(hookSymbol, message, ...someArgs);
89
+ const [message, ...someArgs] = hookArgs;
90
+ return (await this.runHookWithMessageReturn(hookSymbol, message, ...someArgs));
90
91
  }
91
92
  case "void":
92
- return await this.runHookWithVoidReturn(hookSymbol, ...args);
93
+ return (await this.runHookWithVoidReturn(hookSymbol, ...hookArgs));
93
94
  }
94
- /* c8 ignore next 1 */
95
95
  // biome-ignore lint/suspicious/noExplicitAny: error type is not important
96
96
  }
97
- catch (error) {
98
- error.message = `Error running hook ${hook}: ${error.message}`;
97
+ catch (originalError) {
98
+ const newError = new Error(
99
+ /* c8 ignore next */
100
+ `Error running hook ${hook}: ${originalError?.message ?? originalError}`);
101
+ newError.cause = originalError;
99
102
  if (hookSymbol.throwable !== false)
100
- throw error;
101
- this.logger.error(error);
103
+ throw newError;
104
+ this.logger.error(newError);
102
105
  }
106
+ // biome-ignore lint/suspicious/noExplicitAny: fallthrough for non-throwable error paths
107
+ return undefined;
103
108
  }
104
109
  async runHookWithVoidReturn(hookSymbol,
105
110
  // biome-ignore lint/suspicious/noExplicitAny: args can be anything
@@ -1,8 +1,9 @@
1
1
  import type { Signer, SignerOptions } from "@fgiova/aws-signature";
2
2
  import { type Message, MiniSQSClient } from "@fgiova/mini-sqs-client";
3
3
  import type { Pool } from "undici";
4
- import { type HookName } from "./hooks";
4
+ import { type HookCallback, type HookName } from "./hooks";
5
5
  import type { Logger } from "./logger";
6
+ export { getAbortSignal } from "./context";
6
7
  type HandlerOptions = {
7
8
  deleteMessage?: boolean;
8
9
  extendVisibilityTimeout?: boolean;
@@ -23,18 +24,9 @@ type ConsumerOptions = {
23
24
  messageAttributeNames?: string[];
24
25
  attributeNames?: string[];
25
26
  };
26
- type hookReturnMessages = Promise<Message[]> | Message[] | Promise<void> | void | undefined;
27
- type hookReturnMessage = Promise<Message> | Message | Promise<void> | void | undefined;
28
- type hookReturnBoolean = Promise<boolean> | boolean | Promise<void> | void | undefined;
27
+ type ConsumerHookName = Exclude<HookName, "onStart" | "onStop">;
29
28
  export type HooksOptions = {
30
- onPoll?: (messages: Message[]) => hookReturnMessages;
31
- onMessage?: (message: Message) => hookReturnMessage;
32
- onHandlerSuccess?: (message: Message) => hookReturnMessage;
33
- onHandlerTimeout?: (message: Message) => hookReturnBoolean;
34
- onHandlerError?: (message: Message, error: Error) => hookReturnBoolean;
35
- onSuccess?: (message: Message) => hookReturnBoolean;
36
- onError?: (hook: HookName, message: Message, error: Error) => hookReturnBoolean;
37
- onSQSError?: (error: Error, message?: Message) => Promise<void>;
29
+ [H in ConsumerHookName]?: HookCallback<H>;
38
30
  };
39
31
  export type SQSConsumerOptions = {
40
32
  queueARN: string;
@@ -68,16 +60,5 @@ export declare class SQSConsumer {
68
60
  start(): Promise<void>;
69
61
  stop(destroy?: boolean): Promise<void>;
70
62
  get isRunning(): boolean;
71
- addHook(hookName: "onStart", value: (sqsConsumer: SQSConsumerOptions) => void): this;
72
- addHook(hookName: "onStop", value: (sqsConsumer: SQSConsumerOptions) => void): this;
73
- addHook(hookName: "onPoll", value: (messages: Message[]) => Promise<Message[]>): this;
74
- addHook(hookName: "onMessage", value: (message: Message) => Promise<Message>): this;
75
- addHook(hookName: "onHandlerSuccess", value: (message: Message) => Promise<Message>): this;
76
- addHook(hookName: "onHandlerTimeout", value: (message: Message) => Promise<boolean>): this;
77
- addHook(hookName: "onHandlerError", value: (message: Message, error: Error) => Promise<boolean>): this;
78
- addHook(hookName: "onSuccess", value: (message: Message) => Promise<boolean>): this;
79
- addHook(hookName: "onError", value: (hook: HookName, message: Message, error: Error) => Promise<boolean>): this;
80
- addHook(hookName: "onSQSError", value: (error: Error, message?: Message) => Promise<void>): this;
81
- addHook(hookName: HookName, value: Function): this;
63
+ addHook<H extends HookName>(hookName: H, value: HookCallback<H>): this;
82
64
  }
83
- export {};
package/dist/cjs/index.js CHANGED
@@ -3,16 +3,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.SQSConsumer = void 0;
7
- /** biome-ignore-all lint/complexity/noBannedTypes: Function required as generic */
6
+ exports.SQSConsumer = exports.getAbortSignal = void 0;
8
7
  const promises_1 = require("node:timers/promises");
9
8
  const mini_sqs_client_1 = require("@fgiova/mini-sqs-client");
10
9
  // @ts-expect-error
11
10
  const unpromise_1 = require("@watchable/unpromise");
12
11
  // @ts-expect-error
13
12
  const p_map_1 = __importDefault(require("p-map"));
13
+ const context_1 = require("./context");
14
14
  const errors_1 = require("./errors");
15
15
  const hooks_1 = require("./hooks");
16
+ var context_2 = require("./context");
17
+ Object.defineProperty(exports, "getAbortSignal", { enumerable: true, get: function () { return context_2.getAbortSignal; } });
16
18
  class SQSConsumer {
17
19
  constructor(options) {
18
20
  this.messagesOnFly = 0;
@@ -50,6 +52,7 @@ class SQSConsumer {
50
52
  for (const [key, value] of Object.entries(options.hooks)) {
51
53
  if (typeof value !== "function")
52
54
  throw new Error(`${key} must be a function`);
55
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic registration loses type correlation
53
56
  this.addHook(key, value);
54
57
  }
55
58
  }
@@ -77,20 +80,28 @@ class SQSConsumer {
77
80
  }
78
81
  }
79
82
  async runHandler(handler, message) {
83
+ let timeoutId = null;
84
+ const abortController = new AbortController();
80
85
  try {
81
86
  let handlerResult;
82
87
  if (this.handlerOptions.executionTimeout) {
88
+ const handlerPromise = context_1.handlerContext.run({ signal: abortController.signal }, () => handler(message));
89
+ // Swallow late rejection of the loser when timeout wins the race,
90
+ // otherwise it surfaces as an unhandledRejection.
91
+ handlerPromise.catch(() => { });
83
92
  handlerResult = await unpromise_1.Unpromise.race([
84
- handler(message),
93
+ handlerPromise,
85
94
  new Promise((_resolve, reject) => {
86
- setTimeout(() => {
87
- reject(new errors_1.TimeoutError("Handler execution timed out"));
95
+ timeoutId = setTimeout(() => {
96
+ const err = new errors_1.TimeoutError("Handler execution timed out");
97
+ abortController.abort(err);
98
+ reject(err);
88
99
  }, this.handlerOptions.executionTimeout);
89
100
  }),
90
101
  ]);
91
102
  }
92
103
  else {
93
- handlerResult = await handler(message);
104
+ handlerResult = await context_1.handlerContext.run({ signal: abortController.signal }, () => handler(message));
94
105
  }
95
106
  await this.hooks.runHook("onHandlerSuccess", message);
96
107
  return handlerResult;
@@ -103,6 +114,10 @@ class SQSConsumer {
103
114
  await this.hooks.runHook("onHandlerError", message, error);
104
115
  }
105
116
  throw error;
117
+ } /* c8 ignore next 2 */
118
+ finally {
119
+ if (timeoutId)
120
+ clearTimeout(timeoutId);
106
121
  }
107
122
  }
108
123
  haExtendVisibilityTimeout(messages, visibilityTimeout) {
@@ -133,65 +148,75 @@ class SQSConsumer {
133
148
  // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
134
149
  candidatesToDelete.push(result.message.ReceiptHandle);
135
150
  }
136
- if (candidatesToDelete.length)
137
- await this.sqsClient.deleteMessageBatch(this.queueARN, candidatesToDelete);
138
- if (candidateToRelease.length)
139
- await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease, 0);
151
+ }
152
+ if (candidatesToDelete.length) {
153
+ await this.sqsClient.deleteMessageBatch(this.queueARN, candidatesToDelete);
154
+ }
155
+ if (candidateToRelease.length) {
156
+ await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease, 0);
140
157
  }
141
158
  }
142
159
  async pollMessages() {
143
- if (!this.running)
144
- return;
145
- try {
146
- this.polling = true;
147
- /* c8 ignore next 1 */
148
- const visibilityTimeout = this.consumerOptions.visibilityTimeout ?? 30;
149
- const messagesResult = await this.sqsClient.receiveMessage(this.queueARN, {
150
- WaitTimeSeconds: this.consumerOptions.waitTimeSeconds,
151
- MaxNumberOfMessages: this.consumerOptions.itemsPerRequest,
152
- VisibilityTimeout: visibilityTimeout,
153
- MessageAttributeNames: this.consumerOptions.messageAttributeNames,
154
- AttributeNames: this.consumerOptions.attributeNames,
155
- });
156
- /* c8 ignore next 1 */
157
- const messages = messagesResult.Messages ?? [];
158
- const totalMessages = messages.length;
159
- this.messagesOnFly += totalMessages;
160
- await this.hooks.runHook("onPoll", messages);
161
- if (messages.length) {
162
- const haTimeout = this.haExtendVisibilityTimeout(messages, visibilityTimeout);
163
- this.messagesOnFly += messages.length - totalMessages;
164
- if (this.handlerOptions.parallelExecution !== false) {
165
- const results = await (0, p_map_1.default)(messages, async (message) => {
166
- return await this.runOnMessageHook(message);
167
- }, {
168
- concurrency: 10,
169
- stopOnError: false,
170
- });
171
- await this.deleteMessages(results);
172
- this.messagesOnFly -= messages.length;
173
- }
174
- else {
175
- const handlingMessages = [...messages];
176
- for (let i = 0; i < handlingMessages.length; i++) {
177
- const result = await this.runOnMessageHook(handlingMessages[i]);
178
- await this.deleteMessages([result]);
179
- messages.splice(i, 1);
180
- this.messagesOnFly -= 1;
160
+ let consecutiveErrors = 0;
161
+ while (this.running) {
162
+ try {
163
+ this.polling = true;
164
+ /* c8 ignore next 1 */
165
+ const visibilityTimeout = this.consumerOptions.visibilityTimeout ?? 30;
166
+ const messagesResult = await this.sqsClient.receiveMessage(this.queueARN, {
167
+ WaitTimeSeconds: this.consumerOptions.waitTimeSeconds,
168
+ MaxNumberOfMessages: this.consumerOptions.itemsPerRequest,
169
+ VisibilityTimeout: visibilityTimeout,
170
+ MessageAttributeNames: this.consumerOptions.messageAttributeNames,
171
+ AttributeNames: this.consumerOptions.attributeNames,
172
+ });
173
+ const messages = await this.hooks.runHook("onPoll",
174
+ /* c8 ignore next 1 */
175
+ messagesResult.Messages ?? []);
176
+ this.messagesOnFly += messages.length;
177
+ if (messages.length) {
178
+ const haTimeout = this.haExtendVisibilityTimeout(messages, visibilityTimeout);
179
+ try {
180
+ if (this.handlerOptions.parallelExecution !== false) {
181
+ try {
182
+ const results = await (0, p_map_1.default)(messages, async (message) => {
183
+ return await this.runOnMessageHook(message);
184
+ }, {
185
+ concurrency: 10,
186
+ stopOnError: false,
187
+ });
188
+ await this.deleteMessages(results);
189
+ }
190
+ finally {
191
+ this.messagesOnFly -= messages.length;
192
+ }
193
+ }
194
+ else {
195
+ const handlingMessages = [...messages];
196
+ for (let i = 0; i < handlingMessages.length; i++) {
197
+ try {
198
+ const result = await this.runOnMessageHook(handlingMessages[i]);
199
+ await this.deleteMessages([result]);
200
+ }
201
+ finally {
202
+ this.messagesOnFly -= 1;
203
+ }
204
+ }
205
+ }
206
+ }
207
+ finally {
208
+ if (haTimeout)
209
+ clearInterval(haTimeout);
181
210
  }
182
211
  }
183
- if (haTimeout)
184
- clearInterval(haTimeout);
212
+ consecutiveErrors = 0;
185
213
  }
186
- else {
187
- this.messagesOnFly -= totalMessages;
214
+ catch (e) {
215
+ await this.hooks.runHook("onSQSError", e);
216
+ await (0, promises_1.setTimeout)(Math.min(1000 * 2 ** consecutiveErrors++, 30000));
188
217
  }
218
+ this.polling = false;
189
219
  }
190
- catch (e) {
191
- await this.hooks.runHook("onSQSError", e);
192
- }
193
- this.polling = false;
194
- await this.pollMessages();
195
220
  }
196
221
  async start() {
197
222
  try {
@@ -223,7 +248,6 @@ class SQSConsumer {
223
248
  await this.hooks.runHook("onStop", this);
224
249
  }
225
250
  }
226
- /* c8 ignore next 3 */
227
251
  get isRunning() {
228
252
  return this.running;
229
253
  }
@@ -0,0 +1,7 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ type HandlerContext = {
3
+ signal: AbortSignal;
4
+ };
5
+ export declare const handlerContext: AsyncLocalStorage<HandlerContext>;
6
+ export declare function getAbortSignal(): AbortSignal | undefined;
7
+ export {};
@@ -0,0 +1,5 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ export const handlerContext = new AsyncLocalStorage();
3
+ export function getAbortSignal() {
4
+ return handlerContext.getStore()?.signal;
5
+ }
@@ -1,6 +1,5 @@
1
1
  export class TimeoutError extends Error {
2
2
  constructor(message) {
3
- /* c8 ignore next 1 */
4
3
  super(message || "Timeout Error");
5
4
  this.name = "TimeoutError";
6
5
  }
@@ -2,13 +2,56 @@
2
2
  import type { Message } from "@fgiova/mini-sqs-client";
3
3
  import type { Logger } from "./logger.mjs";
4
4
  export type HookName = "onStart" | "onStop" | "onPoll" | "onMessage" | "onHandlerSuccess" | "onHandlerTimeout" | "onHandlerError" | "onSuccess" | "onError" | "onSQSError";
5
+ export type HookSignatureMap = {
6
+ onStart: {
7
+ args: [sqsConsumer: unknown];
8
+ return: undefined;
9
+ };
10
+ onStop: {
11
+ args: [sqsConsumer: unknown];
12
+ return: undefined;
13
+ };
14
+ onPoll: {
15
+ args: [messages: Message[]];
16
+ return: Message[];
17
+ };
18
+ onMessage: {
19
+ args: [message: Message];
20
+ return: Message;
21
+ };
22
+ onHandlerSuccess: {
23
+ args: [message: Message];
24
+ return: Message;
25
+ };
26
+ onHandlerTimeout: {
27
+ args: [message: Message];
28
+ return: boolean;
29
+ };
30
+ onHandlerError: {
31
+ args: [message: Message, error: Error];
32
+ return: boolean;
33
+ };
34
+ onSuccess: {
35
+ args: [message: Message];
36
+ return: boolean;
37
+ };
38
+ onError: {
39
+ args: [hook: HookName, message: Message, error: Error];
40
+ return: boolean;
41
+ };
42
+ onSQSError: {
43
+ args: [error: Error, message?: Message];
44
+ return: undefined;
45
+ };
46
+ };
47
+ export type HookCallback<H extends HookName> = (...args: HookSignatureMap[H]["args"]) => HookSignatureMap[H]["return"] | Promise<HookSignatureMap[H]["return"]> | void | Promise<void> | undefined;
5
48
  export declare class Hooks {
6
49
  private readonly hookSymbols;
7
50
  private readonly hooks;
8
51
  private readonly logger;
9
52
  constructor(logger?: Logger);
10
- addHook(hook: HookName, fn: Function): void;
11
- runHook(hook: HookName, ...args: any[]): Promise<boolean | void | Message | Message[]>;
53
+ addHook<H extends HookName>(hook: H, fn: HookCallback<H>): void;
54
+ runHook<H extends HookName>(hook: H, ...args: HookSignatureMap[H]["args"]): Promise<HookSignatureMap[H]["return"]>;
12
55
  private runHookWithVoidReturn;
13
56
  private runHookWithMessageReturn;
14
57
  private runHookWithBooleanReturn;
@@ -72,31 +72,36 @@ export class Hooks {
72
72
  throw new Error(`Invalid hook ${hook}`);
73
73
  this.hooks[hookSymbol.S]?.push(fn);
74
74
  }
75
- // biome-ignore lint/suspicious/noExplicitAny: args can be anything
76
75
  async runHook(hook, ...args) {
77
76
  const hookSymbol = this.hookSymbols[hook];
77
+ // biome-ignore lint/suspicious/noExplicitAny: internal cast for generic dispatch
78
+ const hookArgs = args;
78
79
  try {
79
80
  if (!hookSymbol)
80
81
  throw new Error(`Invalid hook ${hook}`);
81
82
  switch (hookSymbol.type) {
82
83
  case "boolean-return":
83
- return await this.runHookWithBooleanReturn(hookSymbol, ...args);
84
+ return (await this.runHookWithBooleanReturn(hookSymbol, ...hookArgs));
84
85
  case "message-return": {
85
- const [message, ...someArgs] = args;
86
- return await this.runHookWithMessageReturn(hookSymbol, message, ...someArgs);
86
+ const [message, ...someArgs] = hookArgs;
87
+ return (await this.runHookWithMessageReturn(hookSymbol, message, ...someArgs));
87
88
  }
88
89
  case "void":
89
- return await this.runHookWithVoidReturn(hookSymbol, ...args);
90
+ return (await this.runHookWithVoidReturn(hookSymbol, ...hookArgs));
90
91
  }
91
- /* c8 ignore next 1 */
92
92
  // biome-ignore lint/suspicious/noExplicitAny: error type is not important
93
93
  }
94
- catch (error) {
95
- error.message = `Error running hook ${hook}: ${error.message}`;
94
+ catch (originalError) {
95
+ const newError = new Error(
96
+ /* c8 ignore next */
97
+ `Error running hook ${hook}: ${originalError?.message ?? originalError}`);
98
+ newError.cause = originalError;
96
99
  if (hookSymbol.throwable !== false)
97
- throw error;
98
- this.logger.error(error);
100
+ throw newError;
101
+ this.logger.error(newError);
99
102
  }
103
+ // biome-ignore lint/suspicious/noExplicitAny: fallthrough for non-throwable error paths
104
+ return undefined;
100
105
  }
101
106
  async runHookWithVoidReturn(hookSymbol,
102
107
  // biome-ignore lint/suspicious/noExplicitAny: args can be anything
@@ -1,8 +1,9 @@
1
1
  import type { Signer, SignerOptions } from "@fgiova/aws-signature";
2
2
  import { type Message, MiniSQSClient } from "@fgiova/mini-sqs-client";
3
3
  import type { Pool } from "undici";
4
- import { type HookName } from "./hooks.mjs";
4
+ import { type HookCallback, type HookName } from "./hooks.mjs";
5
5
  import type { Logger } from "./logger.mjs";
6
+ export { getAbortSignal } from "./context.mjs";
6
7
  type HandlerOptions = {
7
8
  deleteMessage?: boolean;
8
9
  extendVisibilityTimeout?: boolean;
@@ -23,18 +24,9 @@ type ConsumerOptions = {
23
24
  messageAttributeNames?: string[];
24
25
  attributeNames?: string[];
25
26
  };
26
- type hookReturnMessages = Promise<Message[]> | Message[] | Promise<void> | void | undefined;
27
- type hookReturnMessage = Promise<Message> | Message | Promise<void> | void | undefined;
28
- type hookReturnBoolean = Promise<boolean> | boolean | Promise<void> | void | undefined;
27
+ type ConsumerHookName = Exclude<HookName, "onStart" | "onStop">;
29
28
  export type HooksOptions = {
30
- onPoll?: (messages: Message[]) => hookReturnMessages;
31
- onMessage?: (message: Message) => hookReturnMessage;
32
- onHandlerSuccess?: (message: Message) => hookReturnMessage;
33
- onHandlerTimeout?: (message: Message) => hookReturnBoolean;
34
- onHandlerError?: (message: Message, error: Error) => hookReturnBoolean;
35
- onSuccess?: (message: Message) => hookReturnBoolean;
36
- onError?: (hook: HookName, message: Message, error: Error) => hookReturnBoolean;
37
- onSQSError?: (error: Error, message?: Message) => Promise<void>;
29
+ [H in ConsumerHookName]?: HookCallback<H>;
38
30
  };
39
31
  export type SQSConsumerOptions = {
40
32
  queueARN: string;
@@ -68,16 +60,5 @@ export declare class SQSConsumer {
68
60
  start(): Promise<void>;
69
61
  stop(destroy?: boolean): Promise<void>;
70
62
  get isRunning(): boolean;
71
- addHook(hookName: "onStart", value: (sqsConsumer: SQSConsumerOptions) => void): this;
72
- addHook(hookName: "onStop", value: (sqsConsumer: SQSConsumerOptions) => void): this;
73
- addHook(hookName: "onPoll", value: (messages: Message[]) => Promise<Message[]>): this;
74
- addHook(hookName: "onMessage", value: (message: Message) => Promise<Message>): this;
75
- addHook(hookName: "onHandlerSuccess", value: (message: Message) => Promise<Message>): this;
76
- addHook(hookName: "onHandlerTimeout", value: (message: Message) => Promise<boolean>): this;
77
- addHook(hookName: "onHandlerError", value: (message: Message, error: Error) => Promise<boolean>): this;
78
- addHook(hookName: "onSuccess", value: (message: Message) => Promise<boolean>): this;
79
- addHook(hookName: "onError", value: (hook: HookName, message: Message, error: Error) => Promise<boolean>): this;
80
- addHook(hookName: "onSQSError", value: (error: Error, message?: Message) => Promise<void>): this;
81
- addHook(hookName: HookName, value: Function): this;
63
+ addHook<H extends HookName>(hookName: H, value: HookCallback<H>): this;
82
64
  }
83
- export {};
@@ -1,12 +1,13 @@
1
- /** biome-ignore-all lint/complexity/noBannedTypes: Function required as generic */
2
1
  import { setTimeout as setTimeoutAsync } from "node:timers/promises";
3
2
  import { MiniSQSClient } from "@fgiova/mini-sqs-client";
4
3
  // @ts-expect-error
5
4
  import { Unpromise } from "@watchable/unpromise";
6
5
  // @ts-expect-error
7
6
  import pMap from "p-map";
7
+ import { handlerContext } from "./context.mjs";
8
8
  import { TimeoutError } from "./errors.mjs";
9
9
  import { Hooks } from "./hooks.mjs";
10
+ export { getAbortSignal } from "./context.mjs";
10
11
  export class SQSConsumer {
11
12
  constructor(options) {
12
13
  this.messagesOnFly = 0;
@@ -44,6 +45,7 @@ export class SQSConsumer {
44
45
  for (const [key, value] of Object.entries(options.hooks)) {
45
46
  if (typeof value !== "function")
46
47
  throw new Error(`${key} must be a function`);
48
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic registration loses type correlation
47
49
  this.addHook(key, value);
48
50
  }
49
51
  }
@@ -71,20 +73,28 @@ export class SQSConsumer {
71
73
  }
72
74
  }
73
75
  async runHandler(handler, message) {
76
+ let timeoutId = null;
77
+ const abortController = new AbortController();
74
78
  try {
75
79
  let handlerResult;
76
80
  if (this.handlerOptions.executionTimeout) {
81
+ const handlerPromise = handlerContext.run({ signal: abortController.signal }, () => handler(message));
82
+ // Swallow late rejection of the loser when timeout wins the race,
83
+ // otherwise it surfaces as an unhandledRejection.
84
+ handlerPromise.catch(() => { });
77
85
  handlerResult = await Unpromise.race([
78
- handler(message),
86
+ handlerPromise,
79
87
  new Promise((_resolve, reject) => {
80
- setTimeout(() => {
81
- reject(new TimeoutError("Handler execution timed out"));
88
+ timeoutId = setTimeout(() => {
89
+ const err = new TimeoutError("Handler execution timed out");
90
+ abortController.abort(err);
91
+ reject(err);
82
92
  }, this.handlerOptions.executionTimeout);
83
93
  }),
84
94
  ]);
85
95
  }
86
96
  else {
87
- handlerResult = await handler(message);
97
+ handlerResult = await handlerContext.run({ signal: abortController.signal }, () => handler(message));
88
98
  }
89
99
  await this.hooks.runHook("onHandlerSuccess", message);
90
100
  return handlerResult;
@@ -97,6 +107,10 @@ export class SQSConsumer {
97
107
  await this.hooks.runHook("onHandlerError", message, error);
98
108
  }
99
109
  throw error;
110
+ } /* c8 ignore next 2 */
111
+ finally {
112
+ if (timeoutId)
113
+ clearTimeout(timeoutId);
100
114
  }
101
115
  }
102
116
  haExtendVisibilityTimeout(messages, visibilityTimeout) {
@@ -127,65 +141,75 @@ export class SQSConsumer {
127
141
  // biome-ignore lint/style/noNonNullAssertion: ReceiptHandle must be present here
128
142
  candidatesToDelete.push(result.message.ReceiptHandle);
129
143
  }
130
- if (candidatesToDelete.length)
131
- await this.sqsClient.deleteMessageBatch(this.queueARN, candidatesToDelete);
132
- if (candidateToRelease.length)
133
- await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease, 0);
144
+ }
145
+ if (candidatesToDelete.length) {
146
+ await this.sqsClient.deleteMessageBatch(this.queueARN, candidatesToDelete);
147
+ }
148
+ if (candidateToRelease.length) {
149
+ await this.sqsClient.changeMessageVisibilityBatch(this.queueARN, candidateToRelease, 0);
134
150
  }
135
151
  }
136
152
  async pollMessages() {
137
- if (!this.running)
138
- return;
139
- try {
140
- this.polling = true;
141
- /* c8 ignore next 1 */
142
- const visibilityTimeout = this.consumerOptions.visibilityTimeout ?? 30;
143
- const messagesResult = await this.sqsClient.receiveMessage(this.queueARN, {
144
- WaitTimeSeconds: this.consumerOptions.waitTimeSeconds,
145
- MaxNumberOfMessages: this.consumerOptions.itemsPerRequest,
146
- VisibilityTimeout: visibilityTimeout,
147
- MessageAttributeNames: this.consumerOptions.messageAttributeNames,
148
- AttributeNames: this.consumerOptions.attributeNames,
149
- });
150
- /* c8 ignore next 1 */
151
- const messages = messagesResult.Messages ?? [];
152
- const totalMessages = messages.length;
153
- this.messagesOnFly += totalMessages;
154
- await this.hooks.runHook("onPoll", messages);
155
- if (messages.length) {
156
- const haTimeout = this.haExtendVisibilityTimeout(messages, visibilityTimeout);
157
- this.messagesOnFly += messages.length - totalMessages;
158
- if (this.handlerOptions.parallelExecution !== false) {
159
- const results = await pMap(messages, async (message) => {
160
- return await this.runOnMessageHook(message);
161
- }, {
162
- concurrency: 10,
163
- stopOnError: false,
164
- });
165
- await this.deleteMessages(results);
166
- this.messagesOnFly -= messages.length;
167
- }
168
- else {
169
- const handlingMessages = [...messages];
170
- for (let i = 0; i < handlingMessages.length; i++) {
171
- const result = await this.runOnMessageHook(handlingMessages[i]);
172
- await this.deleteMessages([result]);
173
- messages.splice(i, 1);
174
- this.messagesOnFly -= 1;
153
+ let consecutiveErrors = 0;
154
+ while (this.running) {
155
+ try {
156
+ this.polling = true;
157
+ /* c8 ignore next 1 */
158
+ const visibilityTimeout = this.consumerOptions.visibilityTimeout ?? 30;
159
+ const messagesResult = await this.sqsClient.receiveMessage(this.queueARN, {
160
+ WaitTimeSeconds: this.consumerOptions.waitTimeSeconds,
161
+ MaxNumberOfMessages: this.consumerOptions.itemsPerRequest,
162
+ VisibilityTimeout: visibilityTimeout,
163
+ MessageAttributeNames: this.consumerOptions.messageAttributeNames,
164
+ AttributeNames: this.consumerOptions.attributeNames,
165
+ });
166
+ const messages = await this.hooks.runHook("onPoll",
167
+ /* c8 ignore next 1 */
168
+ messagesResult.Messages ?? []);
169
+ this.messagesOnFly += messages.length;
170
+ if (messages.length) {
171
+ const haTimeout = this.haExtendVisibilityTimeout(messages, visibilityTimeout);
172
+ try {
173
+ if (this.handlerOptions.parallelExecution !== false) {
174
+ try {
175
+ const results = await pMap(messages, async (message) => {
176
+ return await this.runOnMessageHook(message);
177
+ }, {
178
+ concurrency: 10,
179
+ stopOnError: false,
180
+ });
181
+ await this.deleteMessages(results);
182
+ }
183
+ finally {
184
+ this.messagesOnFly -= messages.length;
185
+ }
186
+ }
187
+ else {
188
+ const handlingMessages = [...messages];
189
+ for (let i = 0; i < handlingMessages.length; i++) {
190
+ try {
191
+ const result = await this.runOnMessageHook(handlingMessages[i]);
192
+ await this.deleteMessages([result]);
193
+ }
194
+ finally {
195
+ this.messagesOnFly -= 1;
196
+ }
197
+ }
198
+ }
199
+ }
200
+ finally {
201
+ if (haTimeout)
202
+ clearInterval(haTimeout);
175
203
  }
176
204
  }
177
- if (haTimeout)
178
- clearInterval(haTimeout);
205
+ consecutiveErrors = 0;
179
206
  }
180
- else {
181
- this.messagesOnFly -= totalMessages;
207
+ catch (e) {
208
+ await this.hooks.runHook("onSQSError", e);
209
+ await setTimeoutAsync(Math.min(1000 * 2 ** consecutiveErrors++, 30000));
182
210
  }
211
+ this.polling = false;
183
212
  }
184
- catch (e) {
185
- await this.hooks.runHook("onSQSError", e);
186
- }
187
- this.polling = false;
188
- await this.pollMessages();
189
213
  }
190
214
  async start() {
191
215
  try {
@@ -217,7 +241,6 @@ export class SQSConsumer {
217
241
  await this.hooks.runHook("onStop", this);
218
242
  }
219
243
  }
220
- /* c8 ignore next 3 */
221
244
  get isRunning() {
222
245
  return this.running;
223
246
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fgiova/sqs-consumer",
3
- "version": "2.2.1",
3
+ "version": "3.1.0",
4
4
  "main": "./dist/cjs/index.js",
5
5
  "types": "./dist/cjs/index.d.ts",
6
6
  "exports": {
@@ -44,7 +44,7 @@
44
44
  "access": "public"
45
45
  },
46
46
  "engines": {
47
- "node": ">=20.14.1"
47
+ "node": "^22.14.0 || >= 24.10.0"
48
48
  },
49
49
  "tap": {
50
50
  "show-full-coverage": true,
@@ -56,25 +56,27 @@
56
56
  ]
57
57
  },
58
58
  "dependencies": {
59
- "@fgiova/mini-sqs-client": "^3.4.0",
59
+ "@fgiova/mini-sqs-client": "^5.0.0",
60
60
  "@watchable/unpromise": "^1.0.2",
61
61
  "p-map": "^7.0.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@fgiova/aws-signature": "^3.2.0"
64
+ "@fgiova/aws-signature": "^4.0.0"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@aws-sdk/client-sqs": "^3.758.0",
68
- "@biomejs/biome": "2.2.2",
68
+ "@biomejs/biome": "2.3.15",
69
69
  "@fgiova/cjs-esm-ts-builder": "^1.2.0",
70
70
  "@semantic-release/changelog": "^6.0.3",
71
71
  "@semantic-release/git": "^10.0.1",
72
- "@tsconfig/node20": "^20.1.4",
73
- "@types/node": "^20.17.23",
74
- "conventional-changelog-conventionalcommits": "^8.0.0",
75
- "semantic-release": "^24.2.3",
72
+ "@semantic-release/npm": "^13.1.4",
73
+ "@tsconfig/node22": "^22.0.5",
74
+ "@types/node": "^22.19.11",
75
+ "conventional-changelog-conventionalcommits": "^9.1.0",
76
+ "dotenv": "^17.2.4",
77
+ "semantic-release": "^25.0.3",
76
78
  "tap": "^21.1.0",
77
- "testcontainers": "^11.5.1",
79
+ "testcontainers": "^11.11.0",
78
80
  "ts-node": "^10.9.2",
79
81
  "typescript": "^5.8.2"
80
82
  }