lambda-deadline-middleware 1.1.0 → 2.0.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
@@ -3,21 +3,65 @@
3
3
 
4
4
  # lambda-deadline-middleware
5
5
 
6
- Zero-dependency AWS SDK v3 middleware that automatically propagates Lambda execution deadlines to outgoing SDK calls via
7
- `AbortController`-based timeouts.
6
+ [![npm version](https://img.shields.io/npm/v/lambda-deadline-middleware)](https://www.npmjs.com/package/lambda-deadline-middleware)
7
+ [![CI](https://github.com/mikkopiu/lambda-deadline-middleware/actions/workflows/ci.yml/badge.svg)](https://github.com/mikkopiu/lambda-deadline-middleware/actions/workflows/ci.yml)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
9
 
9
- When an AWS SDK call hangs inside a Lambda function, the runtime terminates the process at the configured timeout
10
- without throwing an error or giving your code a chance to react. This library prevents that by computing per-request
11
- deadlines from the Lambda's remaining execution time and aborting requests before the hard timeout fires.
10
+ Zero-dependency AWS SDK v3 middleware that propagates Lambda deadlines to outgoing SDK calls via `AbortSignal`.
11
+
12
+ When an AWS SDK call hangs inside a Lambda, the runtime kills the process without throwing an error or giving your code
13
+ a chance to react. This library attaches an `AbortSignal` to every outgoing SDK request so your calls fail fast instead
14
+ of getting killed silently. The signal comes either from an external source you provide or is computed once from the
15
+ Lambda's remaining execution time.
16
+
17
+ ```typescript
18
+ import { withLambdaDeadline, deadlineMiddleware } from "lambda-deadline-middleware";
19
+ import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
20
+
21
+ const dynamodb = new DynamoDBClient({});
22
+ dynamodb.middlewareStack.use(deadlineMiddleware());
23
+
24
+ export const handler = withLambdaDeadline(async (event, context) => {
25
+ return dynamodb.send(new GetItemCommand({ TableName: "my-table", Key: { id: { S: event.id } } }));
26
+ });
27
+ ```
12
28
 
13
29
  ## Features
14
30
 
15
- - Automatic deadline propagation, no manual timeout configuration per call
16
- - Fresh deadline per retry: each SDK retry attempt uses _current_ remaining time
17
- - Signal composition: preserves caller-provided `AbortSignal` via `AbortSignal.any()`
18
- - Zero runtime dependencies (`@smithy/types` is compile-time only)
19
- - Complete no-op when no Lambda context is available
20
- - Branded types prevent millisecond/buffer interchange at compile time
31
+ - Zero runtime dependencies
32
+ - Bring your own `AbortSignal` (from Middy, a manual `AbortController`, or any framework), or let the library compute
33
+ one from `getRemainingTimeInMillis()` at invocation start
34
+ - One signal per invocation: covers all SDK calls and retries within the handler
35
+ - Works alongside per-request `abortSignal` options you pass to `.send()`
36
+ - Complete no-op outside Lambda (safe in local dev and tests)
37
+
38
+ ## How It Works
39
+
40
+ ```mermaid
41
+ flowchart LR
42
+ subgraph Handler startup
43
+ A[Lambda Runtime] --> B[withLambdaDeadline]
44
+ B -->|compute signal once| C[AbortSignal.timeout]
45
+ B --> D[Your Handler]
46
+ end
47
+
48
+ subgraph Per outgoing SDK request
49
+ E[SDK .send] --> F[Deadline Middleware]
50
+ F -->|signal exists?| G[Compose with request signal]
51
+ F -->|no signal| H[Pass through]
52
+ G --> I[HTTP Request]
53
+ H --> I
54
+ end
55
+
56
+ D --> E
57
+
58
+ style F fill:#f9a825,stroke:#f57f17
59
+ style B fill:#66bb6a,stroke:#2e7d32
60
+ ```
61
+
62
+ `withLambdaDeadline` computes `AbortSignal.timeout(remaining - flushBuffer)` once at invocation start and stores it in
63
+ `AsyncLocalStorage`. The middleware reads it on each outgoing request and composes it with any per-request signal. If
64
+ `setDeadlineSignal()` was called, that signal is used instead.
21
65
 
22
66
  ## Requirements
23
67
 
@@ -27,18 +71,17 @@ deadlines from the Lambda's remaining execution time and aborting requests befor
27
71
  ## Installation
28
72
 
29
73
  ```bash
30
- pnpm add lambda-deadline-middleware
74
+ npm install lambda-deadline-middleware
31
75
  ```
32
76
 
33
77
  ## Usage
34
78
 
35
- Setup requires two pieces:
79
+ Setup has two parts:
36
80
 
37
- 1. **Wrap your handler** with `withLambdaDeadline`. This stores the Lambda `context` (specifically
38
- `getRemainingTimeInMillis()`) in `AsyncLocalStorage` so the SDK middleware can read it. The SDK middleware stack has
39
- no access to the Lambda context on its own.
81
+ 1. **Wrap your handler** with `withLambdaDeadline`. Computes the deadline signal once and stores it in
82
+ `AsyncLocalStorage`.
40
83
 
41
- 2. **Register the middleware** on each SDK client via the standard `middlewareStack.use()` pattern.
84
+ 2. **Register the middleware** on each SDK client.
42
85
 
43
86
  ```typescript
44
87
  import { withLambdaDeadline, deadlineMiddleware } from "lambda-deadline-middleware";
@@ -57,66 +100,114 @@ export const handler = withLambdaDeadline(async (event, context) => {
57
100
  });
58
101
  ```
59
102
 
60
- Every SDK call through `dynamodb` now receives a timeout derived from the Lambda's remaining execution time minus a
61
- configurable flush buffer (default: 1000ms).
103
+ Every SDK call through `dynamodb` gets an `AbortSignal` that fires at `remainingTime - flushBuffer` ms from handler
104
+ start.
62
105
 
63
- ## How It Works
106
+ ## External Signal (Middy, manual AbortController, etc.)
64
107
 
65
- ```mermaid
66
- flowchart LR
67
- subgraph Lambda Invocation
68
- direction LR
69
- A[Lambda Runtime] --> B[withLambdaDeadline]
70
- B --> C[Your Handler]
71
- C --> D[SDK .send]
72
- end
108
+ If you already have an `AbortSignal` (from Middy's `timeoutEarlyInMillis`, a manual controller, or anything else), pass
109
+ it in directly:
73
110
 
74
- subgraph Per Attempt
75
- direction LR
76
- D --> E[Deadline Middleware]
77
- E -->|getRemainingTimeInMillis\nminus flush buffer| F[AbortController\n+ setTimeout]
78
- F --> G[HTTP Request]
79
- end
111
+ ```typescript
112
+ import {
113
+ withLambdaDeadline,
114
+ deadlineMiddleware,
115
+ setDeadlineSignal,
116
+ } from "lambda-deadline-middleware";
117
+ import middy from "@middy/core";
118
+ import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
80
119
 
81
- style E fill:#f9a825,stroke:#f57f17
82
- style B fill:#66bb6a,stroke:#2e7d32
120
+ const dynamodb = new DynamoDBClient({});
121
+ dynamodb.middlewareStack.use(deadlineMiddleware());
122
+
123
+ // Middy passes { signal } as the third argument when timeoutEarlyInMillis is set
124
+ const baseHandler = async (event, context, { signal }) => {
125
+ setDeadlineSignal(signal);
126
+ const result = await dynamodb.send(
127
+ new GetItemCommand({
128
+ /* ... */
129
+ }),
130
+ );
131
+ return { statusCode: 200, body: JSON.stringify(result) };
132
+ };
133
+
134
+ export const handler = withLambdaDeadline(
135
+ middy({ timeoutEarlyInMillis: 1000 }).handler(baseHandler),
136
+ );
137
+ ```
138
+
139
+ When an external signal is set via `setDeadlineSignal()`:
140
+
141
+ - It **replaces** the auto-computed signal for the current invocation
142
+ - The signal is composed with any per-request `AbortSignal` via `AbortSignal.any()`
143
+ - You control when and why the abort fires
144
+
145
+ When no external signal is set, the auto-computed signal (from `withLambdaDeadline`) is used.
146
+
147
+ **Important:** `withLambdaDeadline` must wrap the outside. It creates the `AsyncLocalStorage` scope that
148
+ `setDeadlineSignal` writes into.
149
+
150
+ ```typescript
151
+ // ✅ Correct: withLambdaDeadline on the outside
152
+ export const handler = withLambdaDeadline(
153
+ middy({ timeoutEarlyInMillis: 1000 }).handler(baseHandler),
154
+ );
155
+
156
+ // ❌ Wrong: store doesn't exist yet when baseHandler runs
157
+ export const handler = middy({ timeoutEarlyInMillis: 1000 }).handler(
158
+ withLambdaDeadline(baseHandler),
159
+ );
83
160
  ```
84
161
 
85
- `withLambdaDeadline` stores the Lambda context in `AsyncLocalStorage`. The deadline middleware reads it on every attempt
86
- (including retries), computes a fresh timeout, and attaches an `AbortSignal` to the outgoing HTTP request.
162
+ `setDeadlineSignal` only affects AWS SDK calls (via the Smithy middleware stack). For other async work, pass the signal
163
+ yourself:
164
+
165
+ ```typescript
166
+ const baseHandler = async (event, context, { signal }) => {
167
+ setDeadlineSignal(signal);
168
+
169
+ // SDK calls: aborted via middleware
170
+ const item = await dynamodb.send(
171
+ new GetItemCommand({
172
+ /* ... */
173
+ }),
174
+ );
175
+
176
+ // Non-SDK calls: pass signal manually
177
+ const response = await fetch("https://api.example.com/data", { signal });
178
+ };
179
+ ```
87
180
 
88
181
  ## Configuration
89
182
 
90
183
  ### Flush Buffer
91
184
 
92
- The flush buffer is subtracted from the remaining Lambda time to leave room for graceful shutdown and error handling:
185
+ Subtracted from remaining Lambda time to leave room for cleanup:
93
186
 
94
187
  ```typescript
95
- // Default: 1000ms
96
- dynamodb.middlewareStack.use(deadlineMiddleware());
188
+ // Default: 1000ms flush buffer
189
+ export const handler = withLambdaDeadline(myHandler);
97
190
 
98
- // Custom: 500ms
99
- dynamodb.middlewareStack.use(deadlineMiddleware({ flushBufferMs: 500 }));
191
+ // Custom: 500ms flush buffer
192
+ export const handler = withLambdaDeadline(myHandler, { flushBufferMs: 500 });
100
193
  ```
101
194
 
195
+ Only applies to automatic signal computation. With `setDeadlineSignal()`, you control timing yourself.
196
+
102
197
  ## Error Handling
103
198
 
104
- When remaining time is less than or equal to the flush buffer, the middleware throws `DeadlineExceededError` immediately
105
- without dispatching an HTTP request.
199
+ When remaining time flush buffer, `withLambdaDeadline` throws `DeadlineExceededError` without calling the handler:
106
200
 
107
201
  ```typescript
108
202
  import { isDeadlineExceeded } from "lambda-deadline-middleware";
109
203
 
110
204
  try {
111
- await dynamodb.send(
112
- new GetItemCommand({
113
- /* ... */
114
- }),
115
- );
205
+ await handler(event, context);
116
206
  } catch (error) {
117
207
  if (isDeadlineExceeded(error)) {
118
- console.log(`Deadline exceeded: ${error.deadlineMs}ms`);
119
- console.log(`Remaining time was: ${error.remainingMs}ms`);
208
+ console.log(
209
+ `Deadline exceeded: remaining ${error.remainingMs}ms, buffer ${error.flushBufferMs}ms`,
210
+ );
120
211
  }
121
212
  throw error;
122
213
  }
@@ -124,11 +215,11 @@ try {
124
215
 
125
216
  ## Signal Composition
126
217
 
127
- If you pass an `AbortSignal` to a request, the middleware composes both signals:
218
+ If you pass an `AbortSignal` to a specific SDK request, the middleware composes both. Whichever fires first wins:
128
219
 
129
220
  ```typescript
130
221
  const controller = new AbortController();
131
- setTimeout(() => controller.abort(), 5000);
222
+ setTimeout(() => controller.abort(), 2000);
132
223
 
133
224
  await dynamodb.send(
134
225
  new GetItemCommand({
@@ -142,36 +233,36 @@ await dynamodb.send(
142
233
 
143
234
  ## API Reference
144
235
 
145
- ### `withLambdaDeadline(handler)`
236
+ ### `withLambdaDeadline(handler, options?)`
146
237
 
147
- Wraps a Lambda handler to store the Lambda context in `AsyncLocalStorage`. Required for the middleware to access
148
- `getRemainingTimeInMillis()`.
238
+ Wraps a Lambda handler. Computes `AbortSignal.timeout(remaining - flushBuffer)` and stores it in `AsyncLocalStorage`.
149
239
 
150
240
  ```typescript
151
241
  function withLambdaDeadline<TEvent, TResult>(
152
242
  handler: (event: TEvent, context: LambdaContextLike) => Promise<TResult>,
243
+ options?: DeadlineOptions,
153
244
  ): (event: TEvent, context: LambdaContextLike) => Promise<TResult>;
154
245
  ```
155
246
 
156
- ### `deadlineMiddleware(options?)`
247
+ ### `deadlineMiddleware()`
157
248
 
158
- Returns a `Pluggable` for `client.middlewareStack.use()`.
249
+ Returns a `Pluggable` for `client.middlewareStack.use()`. Attaches the stored deadline signal to outgoing requests.
159
250
 
160
251
  ```typescript
161
- function deadlineMiddleware(options?: DeadlineOptions): Pluggable<object, object>;
252
+ function deadlineMiddleware(): Pluggable<object, object>;
162
253
  ```
163
254
 
164
- ### `getRemainingTimeInMillis()`
255
+ ### `setDeadlineSignal(signal)`
165
256
 
166
- Accessor for the current Lambda's remaining execution time. Returns `undefined` outside a Lambda context.
257
+ Replaces the deadline signal for the current invocation. Must be called within a `withLambdaDeadline()` scope.
167
258
 
168
259
  ```typescript
169
- function getRemainingTimeInMillis(): number | undefined;
260
+ function setDeadlineSignal(signal: AbortSignal): void;
170
261
  ```
171
262
 
172
263
  ### `isDeadlineExceeded(error)`
173
264
 
174
- Type guard for deadline-triggered abort errors.
265
+ Type guard for deadline-triggered errors.
175
266
 
176
267
  ```typescript
177
268
  function isDeadlineExceeded(error: unknown): error is DeadlineExceededError;
@@ -179,12 +270,14 @@ function isDeadlineExceeded(error: unknown): error is DeadlineExceededError;
179
270
 
180
271
  ### `DeadlineExceededError`
181
272
 
273
+ Thrown by `withLambdaDeadline` when `remainingTime ≤ flushBufferMs`.
274
+
182
275
  ```typescript
183
276
  class DeadlineExceededError extends Error {
184
277
  readonly name: "DeadlineExceededError";
185
- readonly deadlineMs: Milliseconds;
186
- readonly flushBufferMs: Milliseconds;
187
- readonly remainingMs: Milliseconds;
278
+ readonly deadlineMs: number;
279
+ readonly flushBufferMs: number;
280
+ readonly remainingMs: number;
188
281
  }
189
282
  ```
190
283
 
@@ -198,20 +291,19 @@ interface DeadlineOptions {
198
291
 
199
292
  ### Types
200
293
 
201
- | Type | Description |
202
- | ------------------- | ------------------------------------------------------------ |
203
- | `Milliseconds` | Branded number representing a duration in ms |
204
- | `LambdaContextLike` | Minimal interface: `{ getRemainingTimeInMillis?(): number }` |
294
+ | Type | Description |
295
+ | ------------------- | ---------------------------------------------------------------- |
296
+ | `LambdaContextLike` | Minimal interface: `{ getRemainingTimeInMillis?: () => number }` |
205
297
 
206
298
  ## Reporting Bugs
207
299
 
208
- Found a bug? Please open a [GitHub Issue](https://github.com/mikkopiu/lambda-deadline-middleware/issues/new) with:
300
+ Found a bug? Please open a [GitHub Issue](https://github.com/mikkopiu/lambda-deadline-middleware/issues/new) with a
301
+ minimal reproduction, your Node.js version, and AWS SDK version. For security vulnerabilities, see
302
+ [SECURITY.md](SECURITY.md).
209
303
 
210
- - Your Node.js version and AWS SDK version
211
- - A minimal code snippet reproducing the problem
212
- - Expected vs actual behavior
304
+ ## Changelog
213
305
 
214
- For security vulnerabilities, see [SECURITY.md](SECURITY.md) instead.
306
+ See [GitHub Releases](https://github.com/mikkopiu/lambda-deadline-middleware/releases).
215
307
 
216
308
  ## License
217
309
 
@@ -1,7 +1,30 @@
1
+ import type { DeadlineOptions } from "./types.js";
1
2
  export interface LambdaContextLike {
2
3
  getRemainingTimeInMillis?: () => number;
3
4
  }
4
- export declare const run: <T>(context: LambdaContextLike | null | undefined, fn: () => T) => T;
5
- export declare const getRemainingTimeInMillis: () => number | undefined;
5
+ /**
6
+ * Store an external AbortSignal for the current invocation.
7
+ * When set, the SDK middleware uses this signal directly instead of
8
+ * the auto-computed deadline signal.
9
+ *
10
+ * Call this at the start of your handler, before any SDK calls.
11
+ * The signal is scoped to the current async context (AsyncLocalStorage).
12
+ */
13
+ export declare const setDeadlineSignal: (signal: AbortSignal) => void;
14
+ /**
15
+ * Retrieve the deadline signal for the current invocation, if one exists.
16
+ */
17
+ export declare const getDeadlineSignal: () => AbortSignal | undefined;
18
+ type AsyncHandler<
19
+ TEvent,
20
+ TContext extends LambdaContextLike,
21
+ TResult
22
+ > = (event: TEvent, context: TContext) => Promise<TResult>;
23
+ export declare const withLambdaDeadline: <
24
+ TEvent,
25
+ TContext extends LambdaContextLike,
26
+ TResult
27
+ >(handler: AsyncHandler<TEvent, TContext, TResult>, options?: DeadlineOptions) => AsyncHandler<TEvent, TContext, TResult>;
28
+ export {};
6
29
 
7
30
  //# sourceMappingURL=context-store.d.ts.map
@@ -1 +1 @@
1
- {"mappings":"AAOA,iBAAiB,kBAAkB;CACjC;AACF;AAIA,OAAO,cAAM,MAAO,GAAG,SAAS,sCAAsC,UAAU,MAAI;AAKpF,OAAO,cAAM","names":[],"sources":["src/context-store.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\n// AsyncLocalStorage propagates the Lambda context through the entire async call chain without parameter threading.\n// SDK middleware executes deep in the Smithy stack where we can't pass the context through function signatures.\nexport interface LambdaContextLike {\n getRemainingTimeInMillis?: () => number;\n}\n\nconst contextStorage = new AsyncLocalStorage<LambdaContextLike>();\n\nexport const run = <T>(context: LambdaContextLike | null | undefined, fn: () => T): T => {\n if (context === null || context === undefined) return fn();\n return contextStorage.run(context, fn);\n};\n\nexport const getRemainingTimeInMillis = (): number | undefined => {\n const store = contextStorage.getStore();\n if (store === undefined) return undefined;\n if (typeof store.getRemainingTimeInMillis !== \"function\") return undefined;\n return store.getRemainingTimeInMillis();\n};\n"]}
1
+ {"mappings":"AAOA,cAAc,uBAAuB;AAIrC,iBAAiB,kBAAkB;CACjC;AACF;;;;;;;;;AAgBA,OAAO,cAAM,oBAAqB,QAAQ;;;;AAW1C,OAAO,cAAM,yBAAwB;KAOhC;CAAa;CAAQ,iBAAiB;CAAmB;KAC5D,OAAO,QACP,SAAS,aACN,QAAQ;AAEb,OAAO,cAAM;CACV;CAAQ,iBAAiB;CAAmB;EAC3C,SAAS,aAAa,QAAQ,UAAU,UACxC,UAAU,oBACT,aAAa,QAAQ,UAAU","names":[],"sources":["src/context-store.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nimport { DeadlineExceededError } from \"./error.js\";\n\nimport type { DeadlineOptions } from \"./types.js\";\n\n// AsyncLocalStorage propagates the deadline signal through the entire async call chain.\n// SDK middleware executes deep in the Smithy stack where we can't pass it through function signatures.\nexport interface LambdaContextLike {\n getRemainingTimeInMillis?: () => number;\n}\n\ninterface DeadlineStore {\n signal?: AbortSignal;\n}\n\nconst contextStorage = new AsyncLocalStorage<DeadlineStore>();\n\n/**\n * Store an external AbortSignal for the current invocation.\n * When set, the SDK middleware uses this signal directly instead of\n * the auto-computed deadline signal.\n *\n * Call this at the start of your handler, before any SDK calls.\n * The signal is scoped to the current async context (AsyncLocalStorage).\n */\nexport const setDeadlineSignal = (signal: AbortSignal): void => {\n const store = contextStorage.getStore();\n if (store === undefined) {\n throw new Error(\"setDeadlineSignal() must be called within a withLambdaDeadline() scope\");\n }\n store.signal = signal;\n};\n\n/**\n * Retrieve the deadline signal for the current invocation, if one exists.\n */\nexport const getDeadlineSignal = (): AbortSignal | undefined => {\n const store = contextStorage.getStore();\n if (store === undefined) return undefined;\n return store.signal;\n};\n\n// Handler wrapper — computes the deadline signal once at invocation start and stores it via AsyncLocalStorage.\ntype AsyncHandler<TEvent, TContext extends LambdaContextLike, TResult> = (\n event: TEvent,\n context: TContext,\n) => Promise<TResult>;\n\nexport const withLambdaDeadline =\n <TEvent, TContext extends LambdaContextLike, TResult>(\n handler: AsyncHandler<TEvent, TContext, TResult>,\n options?: DeadlineOptions,\n ): AsyncHandler<TEvent, TContext, TResult> =>\n async (event: TEvent, context: TContext): Promise<TResult> => {\n const store: DeadlineStore = {};\n\n // Compute the auto-deadline signal once, up front.\n // If the user calls setDeadlineSignal() later, it overwrites this.\n // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime safety: context may be null/undefined despite types (e.g. untyped callers)\n if (context !== null && context !== undefined) {\n const remaining =\n typeof context.getRemainingTimeInMillis === \"function\"\n ? context.getRemainingTimeInMillis()\n : undefined;\n\n if (remaining !== undefined) {\n const rawBuffer = options?.flushBufferMs ?? 1000;\n if (rawBuffer < 0) {\n throw new TypeError(`flushBufferMs option must be non-negative, received: ${rawBuffer}`);\n }\n const deadline = remaining - rawBuffer;\n\n if (deadline <= 0) {\n throw new DeadlineExceededError({\n deadlineMs: 0,\n flushBufferMs: rawBuffer,\n remainingMs: remaining,\n });\n }\n\n store.signal = AbortSignal.timeout(deadline);\n }\n }\n\n return contextStorage.run(store, async () => handler(event, context));\n };\n"]}
@@ -1,16 +1,55 @@
1
1
  // SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
+ import { DeadlineExceededError } from "./error.js";
4
5
  const contextStorage = new AsyncLocalStorage();
5
- export const run = (context, fn) => {
6
- if (context === null || context === undefined) return fn();
7
- return contextStorage.run(context, fn);
6
+ /**
7
+ * Store an external AbortSignal for the current invocation.
8
+ * When set, the SDK middleware uses this signal directly instead of
9
+ * the auto-computed deadline signal.
10
+ *
11
+ * Call this at the start of your handler, before any SDK calls.
12
+ * The signal is scoped to the current async context (AsyncLocalStorage).
13
+ */
14
+ export const setDeadlineSignal = (signal) => {
15
+ const store = contextStorage.getStore();
16
+ if (store === undefined) {
17
+ throw new Error("setDeadlineSignal() must be called within a withLambdaDeadline() scope");
18
+ }
19
+ store.signal = signal;
8
20
  };
9
- export const getRemainingTimeInMillis = () => {
21
+ /**
22
+ * Retrieve the deadline signal for the current invocation, if one exists.
23
+ */
24
+ export const getDeadlineSignal = () => {
10
25
  const store = contextStorage.getStore();
11
26
  if (store === undefined) return undefined;
12
- if (typeof store.getRemainingTimeInMillis !== "function") return undefined;
13
- return store.getRemainingTimeInMillis();
27
+ return store.signal;
28
+ };
29
+ export const withLambdaDeadline = (handler, options) => async (event, context) => {
30
+ const store = {};
31
+ // Compute the auto-deadline signal once, up front.
32
+ // If the user calls setDeadlineSignal() later, it overwrites this.
33
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime safety: context may be null/undefined despite types (e.g. untyped callers)
34
+ if (context !== null && context !== undefined) {
35
+ const remaining = typeof context.getRemainingTimeInMillis === "function" ? context.getRemainingTimeInMillis() : undefined;
36
+ if (remaining !== undefined) {
37
+ const rawBuffer = options?.flushBufferMs ?? 1e3;
38
+ if (rawBuffer < 0) {
39
+ throw new TypeError(`flushBufferMs option must be non-negative, received: ${rawBuffer}`);
40
+ }
41
+ const deadline = remaining - rawBuffer;
42
+ if (deadline <= 0) {
43
+ throw new DeadlineExceededError({
44
+ deadlineMs: 0,
45
+ flushBufferMs: rawBuffer,
46
+ remainingMs: remaining
47
+ });
48
+ }
49
+ store.signal = AbortSignal.timeout(deadline);
50
+ }
51
+ }
52
+ return contextStorage.run(store, async () => handler(event, context));
14
53
  };
15
54
 
16
55
  //# sourceMappingURL=context-store.js.map
@@ -1 +1 @@
1
- {"mappings":";;AAGA,SAAS,yBAAyB;AAQlC,MAAM,iBAAiB,IAAI,kBAAqC;AAEhE,OAAO,MAAM,OAAU,SAA+C,OAAmB;CACvF,IAAI,YAAY,QAAQ,YAAY,WAAW,OAAO,GAAG;CACzD,OAAO,eAAe,IAAI,SAAS,EAAE;AACvC;AAEA,OAAO,MAAM,iCAAqD;CAChE,MAAM,QAAQ,eAAe,SAAS;CACtC,IAAI,UAAU,WAAW,OAAO;CAChC,IAAI,OAAO,MAAM,6BAA6B,YAAY,OAAO;CACjE,OAAO,MAAM,yBAAyB;AACxC","names":[],"sources":["src/context-store.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\n// AsyncLocalStorage propagates the Lambda context through the entire async call chain without parameter threading.\n// SDK middleware executes deep in the Smithy stack where we can't pass the context through function signatures.\nexport interface LambdaContextLike {\n getRemainingTimeInMillis?: () => number;\n}\n\nconst contextStorage = new AsyncLocalStorage<LambdaContextLike>();\n\nexport const run = <T>(context: LambdaContextLike | null | undefined, fn: () => T): T => {\n if (context === null || context === undefined) return fn();\n return contextStorage.run(context, fn);\n};\n\nexport const getRemainingTimeInMillis = (): number | undefined => {\n const store = contextStorage.getStore();\n if (store === undefined) return undefined;\n if (typeof store.getRemainingTimeInMillis !== \"function\") return undefined;\n return store.getRemainingTimeInMillis();\n};\n"]}
1
+ {"mappings":";;AAGA,SAAS,yBAAyB;AAElC,SAAS,6BAA6B;AActC,MAAM,iBAAiB,IAAI,kBAAiC;;;;;;;;;AAU5D,OAAO,MAAM,qBAAqB,WAA8B;CAC9D,MAAM,QAAQ,eAAe,SAAS;CACtC,IAAI,UAAU,WAAW;EACvB,MAAM,IAAI,MAAM,wEAAwE;CAC1F;CACA,MAAM,SAAS;AACjB;;;;AAKA,OAAO,MAAM,0BAAmD;CAC9D,MAAM,QAAQ,eAAe,SAAS;CACtC,IAAI,UAAU,WAAW,OAAO;CAChC,OAAO,MAAM;AACf;AAQA,OAAO,MAAM,sBAET,SACA,YAEF,OAAO,OAAe,YAAwC;CAC5D,MAAM,QAAuB,CAAC;;;;CAK9B,IAAI,YAAY,QAAQ,YAAY,WAAW;EAC7C,MAAM,YACJ,OAAO,QAAQ,6BAA6B,aACxC,QAAQ,yBAAyB,IACjC;EAEN,IAAI,cAAc,WAAW;GAC3B,MAAM,YAAY,SAAS,iBAAiB;GAC5C,IAAI,YAAY,GAAG;IACjB,MAAM,IAAI,UAAU,wDAAwD,WAAW;GACzF;GACA,MAAM,WAAW,YAAY;GAE7B,IAAI,YAAY,GAAG;IACjB,MAAM,IAAI,sBAAsB;KAC9B,YAAY;KACZ,eAAe;KACf,aAAa;IACf,CAAC;GACH;GAEA,MAAM,SAAS,YAAY,QAAQ,QAAQ;EAC7C;CACF;CAEA,OAAO,eAAe,IAAI,OAAO,YAAY,QAAQ,OAAO,OAAO,CAAC;AACtE","names":[],"sources":["src/context-store.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nimport { DeadlineExceededError } from \"./error.js\";\n\nimport type { DeadlineOptions } from \"./types.js\";\n\n// AsyncLocalStorage propagates the deadline signal through the entire async call chain.\n// SDK middleware executes deep in the Smithy stack where we can't pass it through function signatures.\nexport interface LambdaContextLike {\n getRemainingTimeInMillis?: () => number;\n}\n\ninterface DeadlineStore {\n signal?: AbortSignal;\n}\n\nconst contextStorage = new AsyncLocalStorage<DeadlineStore>();\n\n/**\n * Store an external AbortSignal for the current invocation.\n * When set, the SDK middleware uses this signal directly instead of\n * the auto-computed deadline signal.\n *\n * Call this at the start of your handler, before any SDK calls.\n * The signal is scoped to the current async context (AsyncLocalStorage).\n */\nexport const setDeadlineSignal = (signal: AbortSignal): void => {\n const store = contextStorage.getStore();\n if (store === undefined) {\n throw new Error(\"setDeadlineSignal() must be called within a withLambdaDeadline() scope\");\n }\n store.signal = signal;\n};\n\n/**\n * Retrieve the deadline signal for the current invocation, if one exists.\n */\nexport const getDeadlineSignal = (): AbortSignal | undefined => {\n const store = contextStorage.getStore();\n if (store === undefined) return undefined;\n return store.signal;\n};\n\n// Handler wrapper — computes the deadline signal once at invocation start and stores it via AsyncLocalStorage.\ntype AsyncHandler<TEvent, TContext extends LambdaContextLike, TResult> = (\n event: TEvent,\n context: TContext,\n) => Promise<TResult>;\n\nexport const withLambdaDeadline =\n <TEvent, TContext extends LambdaContextLike, TResult>(\n handler: AsyncHandler<TEvent, TContext, TResult>,\n options?: DeadlineOptions,\n ): AsyncHandler<TEvent, TContext, TResult> =>\n async (event: TEvent, context: TContext): Promise<TResult> => {\n const store: DeadlineStore = {};\n\n // Compute the auto-deadline signal once, up front.\n // If the user calls setDeadlineSignal() later, it overwrites this.\n // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime safety: context may be null/undefined despite types (e.g. untyped callers)\n if (context !== null && context !== undefined) {\n const remaining =\n typeof context.getRemainingTimeInMillis === \"function\"\n ? context.getRemainingTimeInMillis()\n : undefined;\n\n if (remaining !== undefined) {\n const rawBuffer = options?.flushBufferMs ?? 1000;\n if (rawBuffer < 0) {\n throw new TypeError(`flushBufferMs option must be non-negative, received: ${rawBuffer}`);\n }\n const deadline = remaining - rawBuffer;\n\n if (deadline <= 0) {\n throw new DeadlineExceededError({\n deadlineMs: 0,\n flushBufferMs: rawBuffer,\n remainingMs: remaining,\n });\n }\n\n store.signal = AbortSignal.timeout(deadline);\n }\n }\n\n return contextStorage.run(store, async () => handler(event, context));\n };\n"]}
package/dist/error.d.ts CHANGED
@@ -1,14 +1,13 @@
1
- import type { Milliseconds } from "./types.js";
2
1
  interface DeadlineExceededInit {
3
- readonly deadlineMs: Milliseconds;
4
- readonly flushBufferMs: Milliseconds;
5
- readonly remainingMs: Milliseconds;
2
+ readonly deadlineMs: number;
3
+ readonly flushBufferMs: number;
4
+ readonly remainingMs: number;
6
5
  }
7
6
  export declare class DeadlineExceededError extends Error {
8
7
  override readonly name = "DeadlineExceededError";
9
- readonly deadlineMs: Milliseconds;
10
- readonly flushBufferMs: Milliseconds;
11
- readonly remainingMs: Milliseconds;
8
+ readonly deadlineMs: number;
9
+ readonly flushBufferMs: number;
10
+ readonly remainingMs: number;
12
11
  constructor(init: DeadlineExceededInit);
13
12
  }
14
13
  export declare const isDeadlineExceeded: (error: unknown) => error is DeadlineExceededError;
@@ -1 +1 @@
1
- {"mappings":"AAGA,cAAc,oBAAoB;UAExB,qBAAqB;UACpB,YAAY;UACZ,eAAe;UACf,aAAa;AACxB;AAEA,OAAO,cAAM,8BAA8B,MAAM;CAC/C,kBAAkB,OAAO;CACzB,SAAS,YAAY;CACrB,SAAS,eAAe;CACxB,SAAS,aAAa;CAEtB,YAAY,MAAM;AAQpB;AAIA,OAAO,cAAM,qBAAsB,mBAAiB,SAAS","names":[],"sources":["src/error.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport type { Milliseconds } from \"./types.js\";\n\ninterface DeadlineExceededInit {\n readonly deadlineMs: Milliseconds;\n readonly flushBufferMs: Milliseconds;\n readonly remainingMs: Milliseconds;\n}\n\nexport class DeadlineExceededError extends Error {\n override readonly name = \"DeadlineExceededError\" as const;\n readonly deadlineMs: Milliseconds;\n readonly flushBufferMs: Milliseconds;\n readonly remainingMs: Milliseconds;\n\n constructor(init: DeadlineExceededInit) {\n super(\n `Request deadline exceeded: ${init.deadlineMs}ms deadline (${init.flushBufferMs}ms flush buffer)`,\n );\n this.deadlineMs = init.deadlineMs;\n this.flushBufferMs = init.flushBufferMs;\n this.remainingMs = init.remainingMs;\n }\n}\n\n// Structural check rather than instanceof — works across module boundaries\n// and serialization boundaries where prototype chain may be broken.\nexport const isDeadlineExceeded = (error: unknown): error is DeadlineExceededError => {\n if (error === null || error === undefined) return false;\n if (typeof error !== \"object\") return false;\n return (error as { name?: unknown }).name === \"DeadlineExceededError\";\n};\n"]}
1
+ {"mappings":"UAGU,qBAAqB;UACpB;UACA;UACA;AACX;AAEA,OAAO,cAAM,8BAA8B,MAAM;CAC/C,kBAAkB,OAAO;CACzB,SAAS;CACT,SAAS;CACT,SAAS;CAET,YAAY,MAAM;AAQpB;AAIA,OAAO,cAAM,qBAAsB,mBAAiB,SAAS","names":[],"sources":["src/error.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\ninterface DeadlineExceededInit {\n readonly deadlineMs: number;\n readonly flushBufferMs: number;\n readonly remainingMs: number;\n}\n\nexport class DeadlineExceededError extends Error {\n override readonly name = \"DeadlineExceededError\" as const;\n readonly deadlineMs: number;\n readonly flushBufferMs: number;\n readonly remainingMs: number;\n\n constructor(init: DeadlineExceededInit) {\n super(\n `Request deadline exceeded: ${init.deadlineMs}ms deadline (${init.flushBufferMs}ms flush buffer)`,\n );\n this.deadlineMs = init.deadlineMs;\n this.flushBufferMs = init.flushBufferMs;\n this.remainingMs = init.remainingMs;\n }\n}\n\n// Structural check rather than instanceof — works across module boundaries\n// and serialization boundaries where prototype chain may be broken.\nexport const isDeadlineExceeded = (error: unknown): error is DeadlineExceededError => {\n if (error === null || error === undefined) return false;\n if (typeof error !== \"object\") return false;\n return (error as { name?: unknown }).name === \"DeadlineExceededError\";\n};\n"]}
package/dist/error.js.map CHANGED
@@ -1 +1 @@
1
- {"mappings":"AAWA,OAAO,MAAM,8BAA8B,MAAM;CAC/C,AAAkB,OAAO;CACzB,AAAS;CACT,AAAS;CACT,AAAS;CAET,YAAY,MAA4B;EACtC,MACE,8BAA8B,KAAK,WAAW,eAAe,KAAK,cAAc,iBAClF;EACA,KAAK,aAAa,KAAK;EACvB,KAAK,gBAAgB,KAAK;EAC1B,KAAK,cAAc,KAAK;CAC1B;AACF;;;AAIA,OAAO,MAAM,sBAAsB,UAAmD;CACpF,IAAI,UAAU,QAAQ,UAAU,WAAW,OAAO;CAClD,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,OAAQ,MAA6B,SAAS;AAChD","names":[],"sources":["src/error.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport type { Milliseconds } from \"./types.js\";\n\ninterface DeadlineExceededInit {\n readonly deadlineMs: Milliseconds;\n readonly flushBufferMs: Milliseconds;\n readonly remainingMs: Milliseconds;\n}\n\nexport class DeadlineExceededError extends Error {\n override readonly name = \"DeadlineExceededError\" as const;\n readonly deadlineMs: Milliseconds;\n readonly flushBufferMs: Milliseconds;\n readonly remainingMs: Milliseconds;\n\n constructor(init: DeadlineExceededInit) {\n super(\n `Request deadline exceeded: ${init.deadlineMs}ms deadline (${init.flushBufferMs}ms flush buffer)`,\n );\n this.deadlineMs = init.deadlineMs;\n this.flushBufferMs = init.flushBufferMs;\n this.remainingMs = init.remainingMs;\n }\n}\n\n// Structural check rather than instanceof — works across module boundaries\n// and serialization boundaries where prototype chain may be broken.\nexport const isDeadlineExceeded = (error: unknown): error is DeadlineExceededError => {\n if (error === null || error === undefined) return false;\n if (typeof error !== \"object\") return false;\n return (error as { name?: unknown }).name === \"DeadlineExceededError\";\n};\n"]}
1
+ {"mappings":"AASA,OAAO,MAAM,8BAA8B,MAAM;CAC/C,AAAkB,OAAO;CACzB,AAAS;CACT,AAAS;CACT,AAAS;CAET,YAAY,MAA4B;EACtC,MACE,8BAA8B,KAAK,WAAW,eAAe,KAAK,cAAc,iBAClF;EACA,KAAK,aAAa,KAAK;EACvB,KAAK,gBAAgB,KAAK;EAC1B,KAAK,cAAc,KAAK;CAC1B;AACF;;;AAIA,OAAO,MAAM,sBAAsB,UAAmD;CACpF,IAAI,UAAU,QAAQ,UAAU,WAAW,OAAO;CAClD,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,OAAQ,MAA6B,SAAS;AAChD","names":[],"sources":["src/error.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\ninterface DeadlineExceededInit {\n readonly deadlineMs: number;\n readonly flushBufferMs: number;\n readonly remainingMs: number;\n}\n\nexport class DeadlineExceededError extends Error {\n override readonly name = \"DeadlineExceededError\" as const;\n readonly deadlineMs: number;\n readonly flushBufferMs: number;\n readonly remainingMs: number;\n\n constructor(init: DeadlineExceededInit) {\n super(\n `Request deadline exceeded: ${init.deadlineMs}ms deadline (${init.flushBufferMs}ms flush buffer)`,\n );\n this.deadlineMs = init.deadlineMs;\n this.flushBufferMs = init.flushBufferMs;\n this.remainingMs = init.remainingMs;\n }\n}\n\n// Structural check rather than instanceof — works across module boundaries\n// and serialization boundaries where prototype chain may be broken.\nexport const isDeadlineExceeded = (error: unknown): error is DeadlineExceededError => {\n if (error === null || error === undefined) return false;\n if (typeof error !== \"object\") return false;\n return (error as { name?: unknown }).name === \"DeadlineExceededError\";\n};\n"]}
package/dist/index.d.ts CHANGED
@@ -1,8 +1,7 @@
1
- export { withLambdaDeadline } from "./handler-wrapper.js";
1
+ export { withLambdaDeadline, setDeadlineSignal } from "./context-store.js";
2
2
  export { deadlineMiddleware } from "./middleware.js";
3
3
  export { DeadlineExceededError, isDeadlineExceeded } from "./error.js";
4
- export { getRemainingTimeInMillis } from "./context-store.js";
5
- export type { Milliseconds, DeadlineOptions } from "./types.js";
4
+ export type { DeadlineOptions } from "./types.js";
6
5
  export type { LambdaContextLike } from "./context-store.js";
7
6
 
8
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"mappings":"AAGA,SAAS,0BAA0B;AACnC,SAAS,0BAA0B;AACnC,SAAS,uBAAuB,0BAA0B;AAC1D,SAAS,gCAAgC;AAEzC,cAAc,cAAc,uBAAuB;AAEnD,cAAc,yBAAyB","names":[],"sources":["src/index.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nexport { withLambdaDeadline } from \"./handler-wrapper.js\";\nexport { deadlineMiddleware } from \"./middleware.js\";\nexport { DeadlineExceededError, isDeadlineExceeded } from \"./error.js\";\nexport { getRemainingTimeInMillis } from \"./context-store.js\";\n\nexport type { Milliseconds, DeadlineOptions } from \"./types.js\";\n\nexport type { LambdaContextLike } from \"./context-store.js\";\n"]}
1
+ {"mappings":"AAGA,SAAS,oBAAoB,yBAAyB;AACtD,SAAS,0BAA0B;AACnC,SAAS,uBAAuB,0BAA0B;AAE1D,cAAc,uBAAuB;AACrC,cAAc,yBAAyB","names":[],"sources":["src/index.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nexport { withLambdaDeadline, setDeadlineSignal } from \"./context-store.js\";\nexport { deadlineMiddleware } from \"./middleware.js\";\nexport { DeadlineExceededError, isDeadlineExceeded } from \"./error.js\";\n\nexport type { DeadlineOptions } from \"./types.js\";\nexport type { LambdaContextLike } from \"./context-store.js\";\n"]}
package/dist/index.js CHANGED
@@ -1,8 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors
2
2
  // SPDX-License-Identifier: MIT
3
- export { withLambdaDeadline } from "./handler-wrapper.js";
3
+ export { withLambdaDeadline, setDeadlineSignal } from "./context-store.js";
4
4
  export { deadlineMiddleware } from "./middleware.js";
5
5
  export { DeadlineExceededError, isDeadlineExceeded } from "./error.js";
6
- export { getRemainingTimeInMillis } from "./context-store.js";
7
6
 
8
7
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"mappings":";;AAGA,SAAS,0BAA0B;AACnC,SAAS,0BAA0B;AACnC,SAAS,uBAAuB,0BAA0B;AAC1D,SAAS,gCAAgC","names":[],"sources":["src/index.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nexport { withLambdaDeadline } from \"./handler-wrapper.js\";\nexport { deadlineMiddleware } from \"./middleware.js\";\nexport { DeadlineExceededError, isDeadlineExceeded } from \"./error.js\";\nexport { getRemainingTimeInMillis } from \"./context-store.js\";\n\nexport type { Milliseconds, DeadlineOptions } from \"./types.js\";\n\nexport type { LambdaContextLike } from \"./context-store.js\";\n"]}
1
+ {"mappings":";;AAGA,SAAS,oBAAoB,yBAAyB;AACtD,SAAS,0BAA0B;AACnC,SAAS,uBAAuB,0BAA0B","names":[],"sources":["src/index.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nexport { withLambdaDeadline, setDeadlineSignal } from \"./context-store.js\";\nexport { deadlineMiddleware } from \"./middleware.js\";\nexport { DeadlineExceededError, isDeadlineExceeded } from \"./error.js\";\n\nexport type { DeadlineOptions } from \"./types.js\";\nexport type { LambdaContextLike } from \"./context-store.js\";\n"]}
@@ -1,9 +1,7 @@
1
1
  import type { Pluggable } from "@smithy/types";
2
- import type { DeadlineOptions } from "./types.js";
3
- export declare const composeSignals: (existing: AbortSignal | undefined, deadline: AbortSignal) => AbortSignal;
4
2
  export declare const deadlineMiddleware: <
5
3
  Input extends object,
6
4
  Output extends object
7
- >(options?: DeadlineOptions) => Pluggable<Input, Output>;
5
+ >() => Pluggable<Input, Output>;
8
6
 
9
7
  //# sourceMappingURL=middleware.d.ts.map
@@ -1 +1 @@
1
- {"mappings":"AAGA,cAKE,iBACK;AAMP,cAAc,uBAAuB;AAErC,OAAO,cAAM,iBACX,UAAU,yBACV,UAAU,gBACT;AAKH,OAAO,cAAM;CAAsB;CAAsB;EACvD,UAAU,oBACT,UAAU,OAAO","names":[],"sources":["src/middleware.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport type {\n FinalizeHandler,\n FinalizeHandlerArguments,\n FinalizeHandlerOutput,\n HandlerExecutionContext,\n Pluggable,\n} from \"@smithy/types\";\n\nimport { getRemainingTimeInMillis } from \"./context-store.js\";\nimport { DeadlineExceededError } from \"./error.js\";\nimport { milliseconds } from \"./types.js\";\n\nimport type { DeadlineOptions } from \"./types.js\";\n\nexport const composeSignals = (\n existing: AbortSignal | undefined,\n deadline: AbortSignal,\n): AbortSignal => {\n if (existing === undefined) return deadline;\n return AbortSignal.any([existing, deadline]);\n};\n\nexport const deadlineMiddleware = <Input extends object, Output extends object>(\n options?: DeadlineOptions,\n): Pluggable<Input, Output> => {\n const raw = options?.flushBufferMs ?? 1000;\n if (raw < 0) {\n throw new TypeError(`flushBufferMs option must be non-negative, received: ${raw}`);\n }\n const flushBufferMs = milliseconds(raw);\n\n return {\n applyToStack(stack) {\n // Registered at \"finalizeRequest\" (attempt level) rather than API-call level so each retry gets a deadline\n // computed from the actual remaining time at that moment. API-call level would cache a stale deadline\n // across retries, which grow more dangerous after backoff delays eat into remaining time.\n stack.add(\n (\n next: FinalizeHandler<Input, Output>,\n _context: HandlerExecutionContext,\n ): FinalizeHandler<Input, Output> =>\n async (args: FinalizeHandlerArguments<Input>): Promise<FinalizeHandlerOutput<Output>> => {\n const remaining = getRemainingTimeInMillis();\n if (remaining === undefined) return next(args);\n\n const deadline = remaining - flushBufferMs;\n\n if (deadline <= 0) {\n throw new DeadlineExceededError({\n deadlineMs: milliseconds(0),\n flushBufferMs,\n remainingMs: milliseconds(remaining),\n });\n }\n\n const controller = new AbortController();\n const timeoutId = setTimeout(() => {\n controller.abort(\n new DeadlineExceededError({\n deadlineMs: milliseconds(deadline),\n flushBufferMs,\n remainingMs: milliseconds(remaining),\n }),\n );\n }, deadline);\n\n // `using` guarantees cleanup (clearTimeout) even if next() throws, the promise rejects,\n // or an external abort signal fires — strictly more reliable than try/finally.\n using _timer = {\n [Symbol.dispose]() {\n clearTimeout(timeoutId);\n },\n };\n\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Smithy request is an opaque object; we access optional signal property\n const request = args.request as { signal?: AbortSignal } | undefined;\n const signal = composeSignals(request?.signal, controller.signal);\n const result = await next({\n ...args,\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- spreading opaque Smithy request to add signal\n request: { ...(args.request as object), signal },\n });\n return result;\n },\n {\n step: \"finalizeRequest\",\n name: \"deadlineMiddleware\",\n override: true,\n },\n );\n },\n };\n};\n"]}
1
+ {"mappings":"AAGA,cAKE,iBACK;AAIP,OAAO,cAAM;CAAsB;CAAsB;OAA0B,UACjF,OACA","names":[],"sources":["src/middleware.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport type {\n FinalizeHandler,\n FinalizeHandlerArguments,\n FinalizeHandlerOutput,\n HandlerExecutionContext,\n Pluggable,\n} from \"@smithy/types\";\n\nimport { getDeadlineSignal } from \"./context-store.js\";\n\nexport const deadlineMiddleware = <Input extends object, Output extends object>(): Pluggable<\n Input,\n Output\n> => ({\n applyToStack(stack) {\n stack.add(\n (\n next: FinalizeHandler<Input, Output>,\n _context: HandlerExecutionContext,\n ): FinalizeHandler<Input, Output> =>\n async (args: FinalizeHandlerArguments<Input>): Promise<FinalizeHandlerOutput<Output>> => {\n const deadlineSignal = getDeadlineSignal();\n if (deadlineSignal === undefined) return next(args);\n\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Smithy request is an opaque object\n const request = args.request as { signal?: AbortSignal } | undefined;\n const existing = request?.signal;\n const signal = existing ? AbortSignal.any([existing, deadlineSignal]) : deadlineSignal;\n\n return next({\n ...args,\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- spreading opaque Smithy request to add signal\n request: { ...(args.request as object), signal },\n });\n },\n {\n step: \"finalizeRequest\",\n name: \"deadlineMiddleware\",\n override: true,\n },\n );\n },\n});\n"]}
@@ -1,62 +1,25 @@
1
- import { getRemainingTimeInMillis } from "./context-store.js";
2
- import { DeadlineExceededError } from "./error.js";
3
- import { milliseconds } from "./types.js";
4
- export const composeSignals = (existing, deadline) => {
5
- if (existing === undefined) return deadline;
6
- return AbortSignal.any([existing, deadline]);
7
- };
8
- export const deadlineMiddleware = (options) => {
9
- const raw = options?.flushBufferMs ?? 1e3;
10
- if (raw < 0) {
11
- throw new TypeError(`flushBufferMs option must be non-negative, received: ${raw}`);
12
- }
13
- const flushBufferMs = milliseconds(raw);
14
- return { applyToStack(stack) {
15
- // Registered at "finalizeRequest" (attempt level) rather than API-call level so each retry gets a deadline
16
- // computed from the actual remaining time at that moment. API-call level would cache a stale deadline
17
- // across retries, which grow more dangerous after backoff delays eat into remaining time.
18
- stack.add((next, _context) => async (args) => {
19
- const remaining = getRemainingTimeInMillis();
20
- if (remaining === undefined) return next(args);
21
- const deadline = remaining - flushBufferMs;
22
- if (deadline <= 0) {
23
- throw new DeadlineExceededError({
24
- deadlineMs: milliseconds(0),
25
- flushBufferMs,
26
- remainingMs: milliseconds(remaining)
27
- });
1
+ import { getDeadlineSignal } from "./context-store.js";
2
+ export const deadlineMiddleware = () => ({ applyToStack(stack) {
3
+ stack.add((next, _context) => async (args) => {
4
+ const deadlineSignal = getDeadlineSignal();
5
+ if (deadlineSignal === undefined) return next(args);
6
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Smithy request is an opaque object
7
+ const request = args.request;
8
+ const existing = request?.signal;
9
+ const signal = existing ? AbortSignal.any([existing, deadlineSignal]) : deadlineSignal;
10
+ return next({
11
+ ...args,
12
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- spreading opaque Smithy request to add signal
13
+ request: {
14
+ ...args.request,
15
+ signal
28
16
  }
29
- const controller = new AbortController();
30
- const timeoutId = setTimeout(() => {
31
- controller.abort(new DeadlineExceededError({
32
- deadlineMs: milliseconds(deadline),
33
- flushBufferMs,
34
- remainingMs: milliseconds(remaining)
35
- }));
36
- }, deadline);
37
- // `using` guarantees cleanup (clearTimeout) even if next() throws, the promise rejects,
38
- // or an external abort signal fires — strictly more reliable than try/finally.
39
- using _timer = { [Symbol.dispose]() {
40
- clearTimeout(timeoutId);
41
- } };
42
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Smithy request is an opaque object; we access optional signal property
43
- const request = args.request;
44
- const signal = composeSignals(request?.signal, controller.signal);
45
- const result = await next({
46
- ...args,
47
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- spreading opaque Smithy request to add signal
48
- request: {
49
- ...args.request,
50
- signal
51
- }
52
- });
53
- return result;
54
- }, {
55
- step: "finalizeRequest",
56
- name: "deadlineMiddleware",
57
- override: true
58
17
  });
59
- } };
60
- };
18
+ }, {
19
+ step: "finalizeRequest",
20
+ name: "deadlineMiddleware",
21
+ override: true
22
+ });
23
+ } });
61
24
 
62
25
  //# sourceMappingURL=middleware.js.map
@@ -1 +1 @@
1
- {"mappings":"AAWA,SAAS,gCAAgC;AACzC,SAAS,6BAA6B;AACtC,SAAS,oBAAoB;AAI7B,OAAO,MAAM,kBACX,UACA,aACgB;CAChB,IAAI,aAAa,WAAW,OAAO;CACnC,OAAO,YAAY,IAAI,CAAC,UAAU,QAAQ,CAAC;AAC7C;AAEA,OAAO,MAAM,sBACX,YAC6B;CAC7B,MAAM,MAAM,SAAS,iBAAiB;CACtC,IAAI,MAAM,GAAG;EACX,MAAM,IAAI,UAAU,wDAAwD,KAAK;CACnF;CACA,MAAM,gBAAgB,aAAa,GAAG;CAEtC,OAAO,EACL,aAAa,OAAO;;;;EAIlB,MAAM,KAEF,MACA,aAEA,OAAO,SAAkF;GACvF,MAAM,YAAY,yBAAyB;GAC3C,IAAI,cAAc,WAAW,OAAO,KAAK,IAAI;GAE7C,MAAM,WAAW,YAAY;GAE7B,IAAI,YAAY,GAAG;IACjB,MAAM,IAAI,sBAAsB;KAC9B,YAAY,aAAa,CAAC;KAC1B;KACA,aAAa,aAAa,SAAS;IACrC,CAAC;GACH;GAEA,MAAM,aAAa,IAAI,gBAAgB;GACvC,MAAM,YAAY,iBAAiB;IACjC,WAAW,MACT,IAAI,sBAAsB;KACxB,YAAY,aAAa,QAAQ;KACjC;KACA,aAAa,aAAa,SAAS;IACrC,CAAC,CACH;GACF,GAAG,QAAQ;;;GAIX,MAAM,SAAS,EACb,CAAC,OAAO,WAAW;IACjB,aAAa,SAAS;GACxB,EACF;;GAGA,MAAM,UAAU,KAAK;GACrB,MAAM,SAAS,eAAe,SAAS,QAAQ,WAAW,MAAM;GAChE,MAAM,SAAS,MAAM,KAAK;IACxB,GAAG;;IAEH,SAAS;KAAE,GAAI,KAAK;KAAoB;IAAO;GACjD,CAAC;GACD,OAAO;EACT,GACF;GACE,MAAM;GACN,MAAM;GACN,UAAU;EACZ,CACF;CACF,EACF;AACF","names":[],"sources":["src/middleware.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport type {\n FinalizeHandler,\n FinalizeHandlerArguments,\n FinalizeHandlerOutput,\n HandlerExecutionContext,\n Pluggable,\n} from \"@smithy/types\";\n\nimport { getRemainingTimeInMillis } from \"./context-store.js\";\nimport { DeadlineExceededError } from \"./error.js\";\nimport { milliseconds } from \"./types.js\";\n\nimport type { DeadlineOptions } from \"./types.js\";\n\nexport const composeSignals = (\n existing: AbortSignal | undefined,\n deadline: AbortSignal,\n): AbortSignal => {\n if (existing === undefined) return deadline;\n return AbortSignal.any([existing, deadline]);\n};\n\nexport const deadlineMiddleware = <Input extends object, Output extends object>(\n options?: DeadlineOptions,\n): Pluggable<Input, Output> => {\n const raw = options?.flushBufferMs ?? 1000;\n if (raw < 0) {\n throw new TypeError(`flushBufferMs option must be non-negative, received: ${raw}`);\n }\n const flushBufferMs = milliseconds(raw);\n\n return {\n applyToStack(stack) {\n // Registered at \"finalizeRequest\" (attempt level) rather than API-call level so each retry gets a deadline\n // computed from the actual remaining time at that moment. API-call level would cache a stale deadline\n // across retries, which grow more dangerous after backoff delays eat into remaining time.\n stack.add(\n (\n next: FinalizeHandler<Input, Output>,\n _context: HandlerExecutionContext,\n ): FinalizeHandler<Input, Output> =>\n async (args: FinalizeHandlerArguments<Input>): Promise<FinalizeHandlerOutput<Output>> => {\n const remaining = getRemainingTimeInMillis();\n if (remaining === undefined) return next(args);\n\n const deadline = remaining - flushBufferMs;\n\n if (deadline <= 0) {\n throw new DeadlineExceededError({\n deadlineMs: milliseconds(0),\n flushBufferMs,\n remainingMs: milliseconds(remaining),\n });\n }\n\n const controller = new AbortController();\n const timeoutId = setTimeout(() => {\n controller.abort(\n new DeadlineExceededError({\n deadlineMs: milliseconds(deadline),\n flushBufferMs,\n remainingMs: milliseconds(remaining),\n }),\n );\n }, deadline);\n\n // `using` guarantees cleanup (clearTimeout) even if next() throws, the promise rejects,\n // or an external abort signal fires — strictly more reliable than try/finally.\n using _timer = {\n [Symbol.dispose]() {\n clearTimeout(timeoutId);\n },\n };\n\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Smithy request is an opaque object; we access optional signal property\n const request = args.request as { signal?: AbortSignal } | undefined;\n const signal = composeSignals(request?.signal, controller.signal);\n const result = await next({\n ...args,\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- spreading opaque Smithy request to add signal\n request: { ...(args.request as object), signal },\n });\n return result;\n },\n {\n step: \"finalizeRequest\",\n name: \"deadlineMiddleware\",\n override: true,\n },\n );\n },\n };\n};\n"]}
1
+ {"mappings":"AAWA,SAAS,yBAAyB;AAElC,OAAO,MAAM,4BAGP,EACJ,aAAa,OAAO;CAClB,MAAM,KAEF,MACA,aAEA,OAAO,SAAkF;EACvF,MAAM,iBAAiB,kBAAkB;EACzC,IAAI,mBAAmB,WAAW,OAAO,KAAK,IAAI;;EAGlD,MAAM,UAAU,KAAK;EACrB,MAAM,WAAW,SAAS;EAC1B,MAAM,SAAS,WAAW,YAAY,IAAI,CAAC,UAAU,cAAc,CAAC,IAAI;EAExE,OAAO,KAAK;GACV,GAAG;;GAEH,SAAS;IAAE,GAAI,KAAK;IAAoB;GAAO;EACjD,CAAC;CACH,GACF;EACE,MAAM;EACN,MAAM;EACN,UAAU;CACZ,CACF;AACF,EACF","names":[],"sources":["src/middleware.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport type {\n FinalizeHandler,\n FinalizeHandlerArguments,\n FinalizeHandlerOutput,\n HandlerExecutionContext,\n Pluggable,\n} from \"@smithy/types\";\n\nimport { getDeadlineSignal } from \"./context-store.js\";\n\nexport const deadlineMiddleware = <Input extends object, Output extends object>(): Pluggable<\n Input,\n Output\n> => ({\n applyToStack(stack) {\n stack.add(\n (\n next: FinalizeHandler<Input, Output>,\n _context: HandlerExecutionContext,\n ): FinalizeHandler<Input, Output> =>\n async (args: FinalizeHandlerArguments<Input>): Promise<FinalizeHandlerOutput<Output>> => {\n const deadlineSignal = getDeadlineSignal();\n if (deadlineSignal === undefined) return next(args);\n\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Smithy request is an opaque object\n const request = args.request as { signal?: AbortSignal } | undefined;\n const existing = request?.signal;\n const signal = existing ? AbortSignal.any([existing, deadlineSignal]) : deadlineSignal;\n\n return next({\n ...args,\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- spreading opaque Smithy request to add signal\n request: { ...(args.request as object), signal },\n });\n },\n {\n step: \"finalizeRequest\",\n name: \"deadlineMiddleware\",\n override: true,\n },\n );\n },\n});\n"]}
package/dist/types.d.ts CHANGED
@@ -1,15 +1,5 @@
1
- declare const BrandSymbol: unique symbol;
2
- type Brand<
3
- T,
4
- B extends string
5
- > = T & {
6
- readonly [BrandSymbol]: B;
7
- };
8
- export type Milliseconds = Brand<number, "Milliseconds">;
9
- export declare const milliseconds: (value: number) => Milliseconds;
10
1
  export interface DeadlineOptions {
11
2
  readonly flushBufferMs?: number;
12
3
  }
13
- export {};
14
4
 
15
5
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"mappings":"AAKA,cAAc;KAET;CAAM;CAAG;IAAoB,IAAI;WAAY,cAAc;AAAE;AAElE,YAAY,eAAe,cAAc;AAEzC,OAAO,cAAM,eAAgB,kBAAgB;AAQ7C,iBAAiB,gBAAgB;UACtB;AACX","names":[],"sources":["src/types.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\n// Branded type prevents interchange errors at compile time (e.g. passing seconds where milliseconds are expected).\n// Zero runtime cost. Smart constructor below validates at the boundary and brands the value.\ndeclare const BrandSymbol: unique symbol;\n\ntype Brand<T, B extends string> = T & { readonly [BrandSymbol]: B };\n\nexport type Milliseconds = Brand<number, \"Milliseconds\">;\n\nexport const milliseconds = (value: number): Milliseconds => {\n if (!Number.isFinite(value)) {\n throw new TypeError(`milliseconds value must be finite, received: ${value}`);\n }\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- branded type constructor: value is validated above\n return value as Milliseconds;\n};\n\nexport interface DeadlineOptions {\n readonly flushBufferMs?: number;\n}\n"]}
1
+ {"mappings":"AAGA,iBAAiB,gBAAgB;UACtB;AACX","names":[],"sources":["src/types.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nexport interface DeadlineOptions {\n readonly flushBufferMs?: number;\n}\n"]}
package/dist/types.js CHANGED
@@ -1,9 +1,3 @@
1
- export const milliseconds = (value) => {
2
- if (!Number.isFinite(value)) {
3
- throw new TypeError(`milliseconds value must be finite, received: ${value}`);
4
- }
5
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- branded type constructor: value is validated above
6
- return value;
7
- };
1
+ export {};
8
2
 
9
3
  //# sourceMappingURL=types.js.map
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"mappings":"AAWA,OAAO,MAAM,gBAAgB,UAAgC;CAC3D,IAAI,CAAC,OAAO,SAAS,KAAK,GAAG;EAC3B,MAAM,IAAI,UAAU,gDAAgD,OAAO;CAC7E;;CAEA,OAAO;AACT","names":[],"sources":["src/types.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\n// Branded type prevents interchange errors at compile time (e.g. passing seconds where milliseconds are expected).\n// Zero runtime cost. Smart constructor below validates at the boundary and brands the value.\ndeclare const BrandSymbol: unique symbol;\n\ntype Brand<T, B extends string> = T & { readonly [BrandSymbol]: B };\n\nexport type Milliseconds = Brand<number, \"Milliseconds\">;\n\nexport const milliseconds = (value: number): Milliseconds => {\n if (!Number.isFinite(value)) {\n throw new TypeError(`milliseconds value must be finite, received: ${value}`);\n }\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- branded type constructor: value is validated above\n return value as Milliseconds;\n};\n\nexport interface DeadlineOptions {\n readonly flushBufferMs?: number;\n}\n"]}
1
+ {"mappings":"","names":[],"sources":["src/types.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nexport interface DeadlineOptions {\n readonly flushBufferMs?: number;\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lambda-deadline-middleware",
3
- "version": "1.1.0",
4
- "description": "AWS SDK v3 middleware for automatic Lambda deadline propagation via AbortController-based timeouts",
3
+ "version": "2.0.0",
4
+ "description": "Zero-dependency AWS SDK v3 middleware that propagates Lambda deadlines to outgoing SDK calls via AbortSignal",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -28,19 +28,19 @@
28
28
  },
29
29
  "scripts": {
30
30
  "build": "node --experimental-strip-types scripts/build.ts",
31
- "test": "vitest run",
32
- "test:watch": "vitest",
33
- "bench": "vitest bench --run --outputJson=bench-results.json && node --experimental-strip-types scripts/validate-bench.ts",
31
+ "test": "vitest run --config .config/vitest.config.ts",
32
+ "test:watch": "vitest --config .config/vitest.config.ts",
33
+ "bench": "vitest bench --run --config .config/vitest.config.ts --outputJson=bench-results.json && node --experimental-strip-types scripts/validate-bench.ts",
34
34
  "typecheck": "tsc",
35
- "lint": "oxlint --type-aware --type-check --fix .",
36
- "lint:check": "oxlint --type-aware --type-check .",
35
+ "lint": "oxlint --fix .",
36
+ "lint:check": "oxlint .",
37
37
  "fmt": "oxfmt .",
38
38
  "fmt:check": "oxfmt --check .",
39
- "lint:knip": "knip",
39
+ "lint:knip": "knip --config .config/knip.js",
40
40
  "sast": "scripts/ensure-opengrep.sh scan .",
41
41
  "sast:check": "scripts/ensure-opengrep.sh scan --error .",
42
- "sca": "podman run --rm -v \"$(pwd):/src:Z\" -v trivy-cache:/root/.cache/trivy:Z ghcr.io/aquasecurity/trivy:0.71.0@sha256:016eae51fdcf989332a5404af7e8f625cd5d95d7c0907a221d080a996f556500 fs --scanners vuln --include-dev-deps --exit-code 1 --severity HIGH,CRITICAL /src",
43
- "sca:full": "podman run --rm -v \"$(pwd):/src:Z\" -v trivy-cache:/root/.cache/trivy:Z ghcr.io/aquasecurity/trivy:0.71.0@sha256:016eae51fdcf989332a5404af7e8f625cd5d95d7c0907a221d080a996f556500 fs --scanners vuln,secret,misconfig --include-dev-deps /src",
42
+ "sca": "podman run --rm -v \"$(pwd):/src:Z\" -v trivy-cache:/root/.cache/trivy:Z ghcr.io/aquasecurity/trivy:0.71.0@sha256:016eae51fdcf989332a5404af7e8f625cd5d95d7c0907a221d080a996f556500 fs --config /src/.config/trivy.yaml --ignorefile /src/.config/.trivyignore --scanners vuln --include-dev-deps --exit-code 1 --severity HIGH,CRITICAL /src",
43
+ "sca:full": "podman run --rm -v \"$(pwd):/src:Z\" -v trivy-cache:/root/.cache/trivy:Z ghcr.io/aquasecurity/trivy:0.71.0@sha256:016eae51fdcf989332a5404af7e8f625cd5d95d7c0907a221d080a996f556500 fs --config /src/.config/trivy.yaml --ignorefile /src/.config/.trivyignore --scanners vuln,secret,misconfig --include-dev-deps /src",
44
44
  "actionlint": "podman run --rm -w /repo -v \"$(pwd):/repo:Z\" docker.io/rhysd/actionlint:1.7.12@sha256:9d36088643581e728c969f35141f88139fec77280b2be23c1f66f8e40e1025e7",
45
45
  "security": "pnpm run sast:check && pnpm run sca && pnpm run actionlint",
46
46
  "prepare": "lefthook install",
@@ -48,9 +48,9 @@
48
48
  "release": "semantic-release"
49
49
  },
50
50
  "devDependencies": {
51
- "@aws-sdk/client-dynamodb": "^3.1064.0",
52
- "@aws-sdk/client-s3": "^3.1064.0",
53
- "@aws-sdk/client-sqs": "^3.1064.0",
51
+ "@aws-sdk/client-dynamodb": "^3.1073.0",
52
+ "@aws-sdk/client-s3": "^3.1073.0",
53
+ "@aws-sdk/client-sqs": "^3.1073.0",
54
54
  "@commitlint/cli": "^21.0.2",
55
55
  "@commitlint/config-conventional": "^21.0.2",
56
56
  "@fast-check/vitest": "^0.4.1",
@@ -58,19 +58,19 @@
58
58
  "@semantic-release/github": "12.0.8",
59
59
  "@semantic-release/npm": "13.1.5",
60
60
  "@semantic-release/release-notes-generator": "14.1.1",
61
- "@smithy/types": "^4.14.3",
62
- "@types/node": "^25.9.2",
63
- "@vitest/coverage-v8": "^4.1.8",
61
+ "@smithy/types": "^4.15.0",
62
+ "@types/node": "^25.9.4",
63
+ "@vitest/coverage-v8": "^4.1.9",
64
64
  "conventional-changelog-conventionalcommits": "^9.3.1",
65
- "knip": "^6.16.1",
65
+ "knip": "^6.17.1",
66
66
  "lefthook": "^2.1.9",
67
67
  "oxc-transform": "^0.135.0",
68
68
  "oxfmt": "^0.54.0",
69
- "oxlint": "^1.69.0",
69
+ "oxlint": "^1.70.0",
70
70
  "oxlint-tsgolint": "^0.23.0",
71
71
  "semantic-release": "25.0.5",
72
72
  "typescript": "^6.0.3",
73
- "vitest": "^4.1.8"
73
+ "vitest": "^4.1.9"
74
74
  },
75
75
  "engines": {
76
76
  "node": ">=24"
@@ -3,22 +3,88 @@
3
3
 
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
5
5
 
6
- // AsyncLocalStorage propagates the Lambda context through the entire async call chain without parameter threading.
7
- // SDK middleware executes deep in the Smithy stack where we can't pass the context through function signatures.
6
+ import { DeadlineExceededError } from "./error.js";
7
+
8
+ import type { DeadlineOptions } from "./types.js";
9
+
10
+ // AsyncLocalStorage propagates the deadline signal through the entire async call chain.
11
+ // SDK middleware executes deep in the Smithy stack where we can't pass it through function signatures.
8
12
  export interface LambdaContextLike {
9
13
  getRemainingTimeInMillis?: () => number;
10
14
  }
11
15
 
12
- const contextStorage = new AsyncLocalStorage<LambdaContextLike>();
16
+ interface DeadlineStore {
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ const contextStorage = new AsyncLocalStorage<DeadlineStore>();
13
21
 
14
- export const run = <T>(context: LambdaContextLike | null | undefined, fn: () => T): T => {
15
- if (context === null || context === undefined) return fn();
16
- return contextStorage.run(context, fn);
22
+ /**
23
+ * Store an external AbortSignal for the current invocation.
24
+ * When set, the SDK middleware uses this signal directly instead of
25
+ * the auto-computed deadline signal.
26
+ *
27
+ * Call this at the start of your handler, before any SDK calls.
28
+ * The signal is scoped to the current async context (AsyncLocalStorage).
29
+ */
30
+ export const setDeadlineSignal = (signal: AbortSignal): void => {
31
+ const store = contextStorage.getStore();
32
+ if (store === undefined) {
33
+ throw new Error("setDeadlineSignal() must be called within a withLambdaDeadline() scope");
34
+ }
35
+ store.signal = signal;
17
36
  };
18
37
 
19
- export const getRemainingTimeInMillis = (): number | undefined => {
38
+ /**
39
+ * Retrieve the deadline signal for the current invocation, if one exists.
40
+ */
41
+ export const getDeadlineSignal = (): AbortSignal | undefined => {
20
42
  const store = contextStorage.getStore();
21
43
  if (store === undefined) return undefined;
22
- if (typeof store.getRemainingTimeInMillis !== "function") return undefined;
23
- return store.getRemainingTimeInMillis();
44
+ return store.signal;
24
45
  };
46
+
47
+ // Handler wrapper — computes the deadline signal once at invocation start and stores it via AsyncLocalStorage.
48
+ type AsyncHandler<TEvent, TContext extends LambdaContextLike, TResult> = (
49
+ event: TEvent,
50
+ context: TContext,
51
+ ) => Promise<TResult>;
52
+
53
+ export const withLambdaDeadline =
54
+ <TEvent, TContext extends LambdaContextLike, TResult>(
55
+ handler: AsyncHandler<TEvent, TContext, TResult>,
56
+ options?: DeadlineOptions,
57
+ ): AsyncHandler<TEvent, TContext, TResult> =>
58
+ async (event: TEvent, context: TContext): Promise<TResult> => {
59
+ const store: DeadlineStore = {};
60
+
61
+ // Compute the auto-deadline signal once, up front.
62
+ // If the user calls setDeadlineSignal() later, it overwrites this.
63
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime safety: context may be null/undefined despite types (e.g. untyped callers)
64
+ if (context !== null && context !== undefined) {
65
+ const remaining =
66
+ typeof context.getRemainingTimeInMillis === "function"
67
+ ? context.getRemainingTimeInMillis()
68
+ : undefined;
69
+
70
+ if (remaining !== undefined) {
71
+ const rawBuffer = options?.flushBufferMs ?? 1000;
72
+ if (rawBuffer < 0) {
73
+ throw new TypeError(`flushBufferMs option must be non-negative, received: ${rawBuffer}`);
74
+ }
75
+ const deadline = remaining - rawBuffer;
76
+
77
+ if (deadline <= 0) {
78
+ throw new DeadlineExceededError({
79
+ deadlineMs: 0,
80
+ flushBufferMs: rawBuffer,
81
+ remainingMs: remaining,
82
+ });
83
+ }
84
+
85
+ store.signal = AbortSignal.timeout(deadline);
86
+ }
87
+ }
88
+
89
+ return contextStorage.run(store, async () => handler(event, context));
90
+ };
package/src/error.ts CHANGED
@@ -1,19 +1,17 @@
1
1
  // SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import type { Milliseconds } from "./types.js";
5
-
6
4
  interface DeadlineExceededInit {
7
- readonly deadlineMs: Milliseconds;
8
- readonly flushBufferMs: Milliseconds;
9
- readonly remainingMs: Milliseconds;
5
+ readonly deadlineMs: number;
6
+ readonly flushBufferMs: number;
7
+ readonly remainingMs: number;
10
8
  }
11
9
 
12
10
  export class DeadlineExceededError extends Error {
13
11
  override readonly name = "DeadlineExceededError" as const;
14
- readonly deadlineMs: Milliseconds;
15
- readonly flushBufferMs: Milliseconds;
16
- readonly remainingMs: Milliseconds;
12
+ readonly deadlineMs: number;
13
+ readonly flushBufferMs: number;
14
+ readonly remainingMs: number;
17
15
 
18
16
  constructor(init: DeadlineExceededInit) {
19
17
  super(
package/src/index.ts CHANGED
@@ -1,11 +1,9 @@
1
1
  // SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- export { withLambdaDeadline } from "./handler-wrapper.js";
4
+ export { withLambdaDeadline, setDeadlineSignal } from "./context-store.js";
5
5
  export { deadlineMiddleware } from "./middleware.js";
6
6
  export { DeadlineExceededError, isDeadlineExceeded } from "./error.js";
7
- export { getRemainingTimeInMillis } from "./context-store.js";
8
-
9
- export type { Milliseconds, DeadlineOptions } from "./types.js";
10
7
 
8
+ export type { DeadlineOptions } from "./types.js";
11
9
  export type { LambdaContextLike } from "./context-store.js";
package/src/middleware.ts CHANGED
@@ -9,88 +9,38 @@ import type {
9
9
  Pluggable,
10
10
  } from "@smithy/types";
11
11
 
12
- import { getRemainingTimeInMillis } from "./context-store.js";
13
- import { DeadlineExceededError } from "./error.js";
14
- import { milliseconds } from "./types.js";
15
-
16
- import type { DeadlineOptions } from "./types.js";
17
-
18
- export const composeSignals = (
19
- existing: AbortSignal | undefined,
20
- deadline: AbortSignal,
21
- ): AbortSignal => {
22
- if (existing === undefined) return deadline;
23
- return AbortSignal.any([existing, deadline]);
24
- };
25
-
26
- export const deadlineMiddleware = <Input extends object, Output extends object>(
27
- options?: DeadlineOptions,
28
- ): Pluggable<Input, Output> => {
29
- const raw = options?.flushBufferMs ?? 1000;
30
- if (raw < 0) {
31
- throw new TypeError(`flushBufferMs option must be non-negative, received: ${raw}`);
32
- }
33
- const flushBufferMs = milliseconds(raw);
34
-
35
- return {
36
- applyToStack(stack) {
37
- // Registered at "finalizeRequest" (attempt level) rather than API-call level so each retry gets a deadline
38
- // computed from the actual remaining time at that moment. API-call level would cache a stale deadline
39
- // across retries, which grow more dangerous after backoff delays eat into remaining time.
40
- stack.add(
41
- (
42
- next: FinalizeHandler<Input, Output>,
43
- _context: HandlerExecutionContext,
44
- ): FinalizeHandler<Input, Output> =>
45
- async (args: FinalizeHandlerArguments<Input>): Promise<FinalizeHandlerOutput<Output>> => {
46
- const remaining = getRemainingTimeInMillis();
47
- if (remaining === undefined) return next(args);
48
-
49
- const deadline = remaining - flushBufferMs;
50
-
51
- if (deadline <= 0) {
52
- throw new DeadlineExceededError({
53
- deadlineMs: milliseconds(0),
54
- flushBufferMs,
55
- remainingMs: milliseconds(remaining),
56
- });
57
- }
58
-
59
- const controller = new AbortController();
60
- const timeoutId = setTimeout(() => {
61
- controller.abort(
62
- new DeadlineExceededError({
63
- deadlineMs: milliseconds(deadline),
64
- flushBufferMs,
65
- remainingMs: milliseconds(remaining),
66
- }),
67
- );
68
- }, deadline);
69
-
70
- // `using` guarantees cleanup (clearTimeout) even if next() throws, the promise rejects,
71
- // or an external abort signal fires — strictly more reliable than try/finally.
72
- using _timer = {
73
- [Symbol.dispose]() {
74
- clearTimeout(timeoutId);
75
- },
76
- };
77
-
78
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Smithy request is an opaque object; we access optional signal property
79
- const request = args.request as { signal?: AbortSignal } | undefined;
80
- const signal = composeSignals(request?.signal, controller.signal);
81
- const result = await next({
82
- ...args,
83
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- spreading opaque Smithy request to add signal
84
- request: { ...(args.request as object), signal },
85
- });
86
- return result;
87
- },
88
- {
89
- step: "finalizeRequest",
90
- name: "deadlineMiddleware",
91
- override: true,
12
+ import { getDeadlineSignal } from "./context-store.js";
13
+
14
+ export const deadlineMiddleware = <Input extends object, Output extends object>(): Pluggable<
15
+ Input,
16
+ Output
17
+ > => ({
18
+ applyToStack(stack) {
19
+ stack.add(
20
+ (
21
+ next: FinalizeHandler<Input, Output>,
22
+ _context: HandlerExecutionContext,
23
+ ): FinalizeHandler<Input, Output> =>
24
+ async (args: FinalizeHandlerArguments<Input>): Promise<FinalizeHandlerOutput<Output>> => {
25
+ const deadlineSignal = getDeadlineSignal();
26
+ if (deadlineSignal === undefined) return next(args);
27
+
28
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Smithy request is an opaque object
29
+ const request = args.request as { signal?: AbortSignal } | undefined;
30
+ const existing = request?.signal;
31
+ const signal = existing ? AbortSignal.any([existing, deadlineSignal]) : deadlineSignal;
32
+
33
+ return next({
34
+ ...args,
35
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- spreading opaque Smithy request to add signal
36
+ request: { ...(args.request as object), signal },
37
+ });
92
38
  },
93
- );
94
- },
95
- };
96
- };
39
+ {
40
+ step: "finalizeRequest",
41
+ name: "deadlineMiddleware",
42
+ override: true,
43
+ },
44
+ );
45
+ },
46
+ });
package/src/types.ts CHANGED
@@ -1,22 +1,6 @@
1
1
  // SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- // Branded type prevents interchange errors at compile time (e.g. passing seconds where milliseconds are expected).
5
- // Zero runtime cost. Smart constructor below validates at the boundary and brands the value.
6
- declare const BrandSymbol: unique symbol;
7
-
8
- type Brand<T, B extends string> = T & { readonly [BrandSymbol]: B };
9
-
10
- export type Milliseconds = Brand<number, "Milliseconds">;
11
-
12
- export const milliseconds = (value: number): Milliseconds => {
13
- if (!Number.isFinite(value)) {
14
- throw new TypeError(`milliseconds value must be finite, received: ${value}`);
15
- }
16
- // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- branded type constructor: value is validated above
17
- return value as Milliseconds;
18
- };
19
-
20
4
  export interface DeadlineOptions {
21
5
  readonly flushBufferMs?: number;
22
6
  }
@@ -1,14 +0,0 @@
1
- import type { LambdaContextLike } from "./context-store.js";
2
- type AsyncHandler<
3
- TEvent,
4
- TContext extends LambdaContextLike,
5
- TResult
6
- > = (event: TEvent, context: TContext) => Promise<TResult>;
7
- export declare const withLambdaDeadline: <
8
- TEvent,
9
- TContext extends LambdaContextLike,
10
- TResult
11
- >(handler: AsyncHandler<TEvent, TContext, TResult>) => AsyncHandler<TEvent, TContext, TResult>;
12
- export {};
13
-
14
- //# sourceMappingURL=handler-wrapper.d.ts.map
@@ -1 +0,0 @@
1
- {"mappings":"AAKA,cAAc,yBAAyB;KAElC;CAAa;CAAQ,iBAAiB;CAAmB;KAC5D,OAAO,QACP,SAAS,aACN,QAAQ;AAEb,OAAO,cAAM;CACV;CAAQ,iBAAiB;CAAmB;EAC3C,SAAS,aAAa,QAAQ,UAAU,aACvC,aAAa,QAAQ,UAAU","names":[],"sources":["src/handler-wrapper.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport { run } from \"./context-store.js\";\n\nimport type { LambdaContextLike } from \"./context-store.js\";\n\ntype AsyncHandler<TEvent, TContext extends LambdaContextLike, TResult> = (\n event: TEvent,\n context: TContext,\n) => Promise<TResult>;\n\nexport const withLambdaDeadline =\n <TEvent, TContext extends LambdaContextLike, TResult>(\n handler: AsyncHandler<TEvent, TContext, TResult>,\n ): AsyncHandler<TEvent, TContext, TResult> =>\n async (event: TEvent, context: TContext): Promise<TResult> =>\n run(context, async () => handler(event, context));\n"]}
@@ -1,6 +0,0 @@
1
- // SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors
2
- // SPDX-License-Identifier: MIT
3
- import { run } from "./context-store.js";
4
- export const withLambdaDeadline = (handler) => async (event, context) => run(context, async () => handler(event, context));
5
-
6
- //# sourceMappingURL=handler-wrapper.js.map
@@ -1 +0,0 @@
1
- {"mappings":";;AAGA,SAAS,WAAW;AASpB,OAAO,MAAM,sBAET,YAEF,OAAO,OAAe,YACpB,IAAI,SAAS,YAAY,QAAQ,OAAO,OAAO,CAAC","names":[],"sources":["src/handler-wrapper.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport { run } from \"./context-store.js\";\n\nimport type { LambdaContextLike } from \"./context-store.js\";\n\ntype AsyncHandler<TEvent, TContext extends LambdaContextLike, TResult> = (\n event: TEvent,\n context: TContext,\n) => Promise<TResult>;\n\nexport const withLambdaDeadline =\n <TEvent, TContext extends LambdaContextLike, TResult>(\n handler: AsyncHandler<TEvent, TContext, TResult>,\n ): AsyncHandler<TEvent, TContext, TResult> =>\n async (event: TEvent, context: TContext): Promise<TResult> =>\n run(context, async () => handler(event, context));\n"]}
@@ -1,18 +0,0 @@
1
- // SPDX-FileCopyrightText: 2026 lambda-deadline-middleware contributors
2
- // SPDX-License-Identifier: MIT
3
-
4
- import { run } from "./context-store.js";
5
-
6
- import type { LambdaContextLike } from "./context-store.js";
7
-
8
- type AsyncHandler<TEvent, TContext extends LambdaContextLike, TResult> = (
9
- event: TEvent,
10
- context: TContext,
11
- ) => Promise<TResult>;
12
-
13
- export const withLambdaDeadline =
14
- <TEvent, TContext extends LambdaContextLike, TResult>(
15
- handler: AsyncHandler<TEvent, TContext, TResult>,
16
- ): AsyncHandler<TEvent, TContext, TResult> =>
17
- async (event: TEvent, context: TContext): Promise<TResult> =>
18
- run(context, async () => handler(event, context));