lambda-deadline-middleware 0.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +278 -0
  3. package/SECURITY.md +95 -0
  4. package/dist/config.d.ts +4 -0
  5. package/dist/config.d.ts.map +1 -0
  6. package/dist/config.js +15 -0
  7. package/dist/config.js.map +1 -0
  8. package/dist/context-store.d.ts +7 -0
  9. package/dist/context-store.d.ts.map +1 -0
  10. package/dist/context-store.js +24 -0
  11. package/dist/context-store.js.map +1 -0
  12. package/dist/error.d.ts +17 -0
  13. package/dist/error.d.ts.map +1 -0
  14. package/dist/error.js +21 -0
  15. package/dist/error.js.map +1 -0
  16. package/dist/handler-wrapper.d.ts +13 -0
  17. package/dist/handler-wrapper.d.ts.map +1 -0
  18. package/dist/handler-wrapper.js +8 -0
  19. package/dist/handler-wrapper.js.map +1 -0
  20. package/dist/index.d.ts +8 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +8 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/middleware.d.ts +12 -0
  25. package/dist/middleware.d.ts.map +1 -0
  26. package/dist/middleware.js +76 -0
  27. package/dist/middleware.js.map +1 -0
  28. package/dist/registration.d.ts +10 -0
  29. package/dist/registration.d.ts.map +1 -0
  30. package/dist/registration.js +23 -0
  31. package/dist/registration.js.map +1 -0
  32. package/dist/telemetry.d.ts +5 -0
  33. package/dist/telemetry.d.ts.map +1 -0
  34. package/dist/telemetry.js +82 -0
  35. package/dist/telemetry.js.map +1 -0
  36. package/dist/types.d.ts +33 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +19 -0
  39. package/dist/types.js.map +1 -0
  40. package/package.json +73 -0
  41. package/src/config.ts +16 -0
  42. package/src/context-store.ts +36 -0
  43. package/src/error.ts +34 -0
  44. package/src/handler-wrapper.ts +19 -0
  45. package/src/index.ts +18 -0
  46. package/src/middleware.ts +109 -0
  47. package/src/registration.ts +36 -0
  48. package/src/telemetry.ts +129 -0
  49. package/src/types.ts +50 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 lambda-deadline-middleware contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,278 @@
1
+ <!-- SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors -->
2
+ <!-- SPDX-License-Identifier: MIT -->
3
+
4
+ # lambda-deadline-middleware
5
+
6
+ Zero-dependency AWS SDK v3 middleware that automatically propagates Lambda execution deadlines to outgoing SDK calls via `AbortController`-based timeouts.
7
+
8
+ When an AWS SDK call hangs inside a Lambda function, the runtime terminates the process at the configured timeout — destroying in-flight OpenTelemetry/X-Ray spans without export. This library prevents that by computing per-request deadlines from the Lambda's remaining execution time and aborting requests before the hard timeout fires.
9
+
10
+ ## Features
11
+
12
+ - **Automatic deadline propagation** — no manual timeout configuration per call
13
+ - **Fresh deadline per retry** — each SDK retry attempt gets a deadline based on _current_ remaining time
14
+ - **Signal composition** — preserves caller-provided `AbortSignal` via `AbortSignal.any()`
15
+ - **Zero runtime dependencies** — uses `@smithy/types` for compile-time types only
16
+ - **Safe outside Lambda** — complete no-op when no Lambda context is available
17
+ - **OpenTelemetry integration** — optional span events on deadline aborts (detected dynamically)
18
+ - **Type-safe** — branded types prevent millisecond/buffer interchange at compile time
19
+
20
+ ## Requirements
21
+
22
+ - Node.js ≥ 24
23
+ - AWS SDK v3 (built against `@smithy/types` ≥ 3.0.0)
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pnpm add lambda-deadline-middleware
29
+ ```
30
+
31
+ ## Quick Start
32
+
33
+ ```typescript
34
+ import { withLambdaDeadline, withDeadline } from "lambda-deadline-middleware";
35
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
36
+
37
+ // 1. Wrap your Lambda handler
38
+ export const handler = withLambdaDeadline(async (event, context) => {
39
+ const result = await dynamodb.send(
40
+ new GetItemCommand({
41
+ /* ... */
42
+ }),
43
+ );
44
+ return { statusCode: 200, body: JSON.stringify(result) };
45
+ });
46
+
47
+ // 2. Register middleware on SDK clients
48
+ const dynamodb = withDeadline(new DynamoDBClient({}));
49
+ ```
50
+
51
+ That's it. Every SDK call through `dynamodb` will automatically receive a timeout derived from the Lambda's remaining execution time minus a 1000ms flush buffer.
52
+
53
+ ## Architecture
54
+
55
+ ```mermaid
56
+ graph TB
57
+ subgraph "Lambda Runtime"
58
+ LR[Lambda Runtime] -->|invokes| HW[Handler Wrapper<br/>withLambdaDeadline]
59
+ HW -->|stores context| ALS[AsyncLocalStorage<br/>Lambda Context Store]
60
+ HW -->|delegates to| UH[User Handler]
61
+ UH -->|sends command| SDK[AWS SDK v3 Client]
62
+
63
+ subgraph "SDK Middleware Stack"
64
+ direction TB
65
+ INIT[Initialize Step]
66
+ FIN[Finalize Step]
67
+ RETRY[Retry Middleware]
68
+ DM[Deadline Middleware<br/>← attempt level]
69
+ HTTP[HTTP Handler]
70
+
71
+ INIT --> FIN --> RETRY
72
+ RETRY -->|per attempt| DM --> HTTP
73
+ end
74
+
75
+ DM -->|reads remaining time| ALS
76
+ DM -->|creates| AC[AbortController<br/>+ setTimeout]
77
+ AC -->|signal attached to| HTTP
78
+ end
79
+
80
+ SDK -->|middleware stack| INIT
81
+
82
+ style DM fill:#f9a825,stroke:#f57f17
83
+ style ALS fill:#42a5f5,stroke:#1565c0
84
+ style HW fill:#66bb6a,stroke:#2e7d32
85
+ ```
86
+
87
+ The middleware registers at the **attempt level** of the SDK stack — it executes once per HTTP request dispatch including retries, computing a fresh deadline each time.
88
+
89
+ ## Configuration
90
+
91
+ ### Flush Buffer
92
+
93
+ The flush buffer is subtracted from the remaining Lambda time to leave room for telemetry export and error handling:
94
+
95
+ ```typescript
96
+ // Default: 1000ms flush buffer
97
+ const client = withDeadline(new DynamoDBClient({}));
98
+
99
+ // Custom: 500ms flush buffer
100
+ const client = withDeadline(new DynamoDBClient({}), { flushBufferMs: 500 });
101
+
102
+ // No buffer: use full remaining time
103
+ const client = withDeadline(new DynamoDBClient({}), { flushBufferMs: 0 });
104
+ ```
105
+
106
+ ### Handler-Level Defaults
107
+
108
+ ```typescript
109
+ export const handler = withLambdaDeadline(
110
+ async (event, context) => {
111
+ /* ... */
112
+ },
113
+ { flushBufferMs: 2000, telemetryEnabled: false },
114
+ );
115
+ ```
116
+
117
+ ### Telemetry
118
+
119
+ OpenTelemetry integration is detected dynamically. If `@opentelemetry/api` is installed, span events are emitted on deadline aborts. Disable with:
120
+
121
+ ```typescript
122
+ const client = withDeadline(new DynamoDBClient({}), { telemetryEnabled: false });
123
+ ```
124
+
125
+ ## API Reference
126
+
127
+ ### `withLambdaDeadline(handler, options?)`
128
+
129
+ Wraps a Lambda handler to initialize the context store.
130
+
131
+ ```typescript
132
+ function withLambdaDeadline<TEvent, TResult>(
133
+ handler: (event: TEvent, context: LambdaContextLike) => Promise<TResult>,
134
+ options?: DeadlineOptions,
135
+ ): (event: TEvent, context: LambdaContextLike) => Promise<TResult>;
136
+ ```
137
+
138
+ ### `withDeadline(client, options?)`
139
+
140
+ Registers the deadline middleware on an SDK client. Returns the same client instance. Idempotent — subsequent calls on the same client are no-ops.
141
+
142
+ ```typescript
143
+ function withDeadline<T extends { middlewareStack: { use: Function } }>(
144
+ client: T,
145
+ options?: DeadlineOptions,
146
+ ): T;
147
+ ```
148
+
149
+ ### `deadlineMiddleware(options?)`
150
+
151
+ Factory returning a `Pluggable` object for `client.middlewareStack.use()`.
152
+
153
+ ```typescript
154
+ function deadlineMiddleware(options?: DeadlineOptions): Pluggable<object, object>;
155
+ ```
156
+
157
+ ### `getRemainingTimeInMillis()`
158
+
159
+ Accessor for the current Lambda's remaining execution time. Returns `undefined` outside a Lambda context.
160
+
161
+ ```typescript
162
+ function getRemainingTimeInMillis(): number | undefined;
163
+ ```
164
+
165
+ ### `DeadlineExceededError`
166
+
167
+ Error thrown when a request is aborted due to deadline expiration.
168
+
169
+ ```typescript
170
+ class DeadlineExceededError extends Error {
171
+ readonly name: "DeadlineExceededError";
172
+ readonly deadlineMs: Milliseconds;
173
+ readonly flushBufferMs: FlushBufferMs;
174
+ readonly remainingMs: Milliseconds;
175
+ }
176
+ ```
177
+
178
+ ### `isDeadlineExceeded(error)`
179
+
180
+ Type guard for deadline-triggered abort errors.
181
+
182
+ ```typescript
183
+ function isDeadlineExceeded(error: unknown): error is DeadlineExceededError;
184
+ ```
185
+
186
+ ### `DeadlineOptions`
187
+
188
+ ```typescript
189
+ interface DeadlineOptions {
190
+ readonly flushBufferMs?: number; // Default: 1000
191
+ readonly telemetryEnabled?: boolean; // Default: true
192
+ }
193
+ ```
194
+
195
+ ### Types
196
+
197
+ | Type | Description |
198
+ | -------------------------- | ---------------------------------------------------------------------------- |
199
+ | `Milliseconds` | Branded number representing a duration in ms |
200
+ | `FlushBufferMs` | Branded number for the flush buffer |
201
+ | `RequestDeadlineMs` | Branded number for a computed deadline |
202
+ | `DeadlineComputation` | Discriminated union: `"deadline"` \| `"insufficient-time"` \| `"no-context"` |
203
+ | `DeadlineMiddlewareConfig` | Validated configuration (internal) |
204
+ | `LambdaContextLike` | Minimal interface: `{ getRemainingTimeInMillis?(): number }` |
205
+
206
+ ## Error Handling
207
+
208
+ ```typescript
209
+ import { isDeadlineExceeded } from "lambda-deadline-middleware";
210
+
211
+ try {
212
+ await client.send(
213
+ new GetItemCommand({
214
+ /* ... */
215
+ }),
216
+ );
217
+ } catch (error) {
218
+ if (isDeadlineExceeded(error)) {
219
+ console.log(`Deadline exceeded: ${error.deadlineMs}ms`);
220
+ console.log(`Remaining time was: ${error.remainingMs}ms`);
221
+ // Handle graceful degradation
222
+ }
223
+ throw error;
224
+ }
225
+ ```
226
+
227
+ When remaining time is less than or equal to the flush buffer, the middleware throws `DeadlineExceededError` immediately without dispatching an HTTP request.
228
+
229
+ ## Advanced Usage
230
+
231
+ ### Direct Pluggable Registration
232
+
233
+ ```typescript
234
+ import { deadlineMiddleware } from "lambda-deadline-middleware";
235
+
236
+ const client = new S3Client({});
237
+ client.middlewareStack.use(deadlineMiddleware({ flushBufferMs: 750 }));
238
+ ```
239
+
240
+ ### Combining with Existing Abort Signals
241
+
242
+ The middleware composes signals — your existing signal is preserved:
243
+
244
+ ```typescript
245
+ const controller = new AbortController();
246
+ setTimeout(() => controller.abort(), 5000); // your own timeout
247
+
248
+ await client.send(
249
+ new GetItemCommand({
250
+ /* ... */
251
+ }),
252
+ {
253
+ abortSignal: controller.signal, // both signals are respected
254
+ },
255
+ );
256
+ ```
257
+
258
+ ### Multiple Clients
259
+
260
+ ```typescript
261
+ const dynamodb = withDeadline(new DynamoDBClient({}));
262
+ const sqs = withDeadline(new SQSClient({}), { flushBufferMs: 2000 });
263
+ const s3 = withDeadline(new S3Client({}), { flushBufferMs: 500 });
264
+ ```
265
+
266
+ ## Reporting Bugs
267
+
268
+ Found a bug? Please open a [GitHub Issue](https://github.com/mikkopiu/lambda-deadline-middleware/issues/new) with:
269
+
270
+ - Your Node.js version and AWS SDK version
271
+ - A minimal code snippet reproducing the problem
272
+ - Expected vs actual behavior
273
+
274
+ For security vulnerabilities, see [SECURITY.md](SECURITY.md) instead.
275
+
276
+ ## License
277
+
278
+ [MIT](LICENSE)
package/SECURITY.md ADDED
@@ -0,0 +1,95 @@
1
+ <!-- SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors -->
2
+ <!-- SPDX-License-Identifier: MIT -->
3
+
4
+ # Security
5
+
6
+ This document describes the supply chain security practices for `lambda-deadline-middleware`.
7
+
8
+ ## Provenance Verification
9
+
10
+ Every published release includes SLSA Level 3 provenance attestation, generated
11
+ automatically by GitHub Actions using the `--provenance` flag during `npm publish`.
12
+
13
+ ### Verifying provenance
14
+
15
+ ```bash
16
+ npm audit signatures
17
+ ```
18
+
19
+ This command verifies that the package was:
20
+
21
+ - Built from the source repository (not a compromised local machine)
22
+ - Published by the expected GitHub Actions workflow
23
+ - Signed with a valid sigstore certificate tied to the workflow identity
24
+
25
+ You can also inspect the provenance bundle directly:
26
+
27
+ ```bash
28
+ npm provenance --package-name lambda-deadline-middleware
29
+ ```
30
+
31
+ ## SBOM (Software Bill of Materials)
32
+
33
+ Each release includes a CycloneDX SBOM (`sbom.cdx.json`) attached as a GitHub
34
+ Release asset. The SBOM lists all runtime and development dependencies with their
35
+ versions, providing full transparency into the package's dependency tree.
36
+
37
+ ### Generating the SBOM locally
38
+
39
+ ```bash
40
+ pnpm run sbom
41
+ ```
42
+
43
+ This produces `sbom.cdx.json` in the project root using pnpm's native CycloneDX SBOM generation.
44
+
45
+ ### SBOM format
46
+
47
+ - **Specification**: CycloneDX v1.5
48
+ - **Format**: JSON
49
+ - **Tool**: `pnpm sbom` (native, since pnpm 11)
50
+ - **Contents**: Component inventory including direct and transitive dependencies
51
+
52
+ ## Sigstore Signing
53
+
54
+ All published artifacts are signed using **keyless sigstore signing** via OIDC
55
+ identity federation. This means:
56
+
57
+ - **No long-lived signing keys**: Signing happens via short-lived certificates
58
+ issued by Fulcio, tied to the GitHub Actions OIDC identity.
59
+ - **Transparency log**: All signatures are recorded in the Rekor transparency log,
60
+ providing a tamper-evident audit trail.
61
+ - **Verification without publisher keys**: Consumers verify signatures using the
62
+ sigstore root of trust, not a publisher-managed key.
63
+
64
+ ### How it works in CI
65
+
66
+ 1. The GitHub Actions release workflow requests an OIDC token from GitHub.
67
+ 2. The OIDC token is exchanged with Fulcio for a short-lived signing certificate.
68
+ 3. The npm package is signed with the certificate during `npm publish --provenance`.
69
+ 4. The signature and certificate are recorded in the Rekor transparency log.
70
+ 5. npm stores the provenance bundle alongside the package tarball.
71
+
72
+ ### Verifying signatures
73
+
74
+ Signature verification is automatic when running `npm audit signatures`. The npm
75
+ CLI checks:
76
+
77
+ - The certificate was issued by Fulcio
78
+ - The OIDC identity matches the expected repository and workflow
79
+ - The signature is recorded in the Rekor transparency log
80
+ - The package content matches the signed digest
81
+
82
+ ## Reporting Vulnerabilities
83
+
84
+ If you discover a security vulnerability in this package, please report it
85
+ responsibly by opening a [GitHub Security Advisory](https://github.com/mikkopiu/lambda-deadline-middleware/security/advisories/new) on this repository. Do not
86
+ file a public issue for security vulnerabilities.
87
+
88
+ ### Response Timeline
89
+
90
+ We aim to acknowledge vulnerability reports within 72 hours. For critical
91
+ issues, we target a fix or mitigation plan within 14 days. Lower-severity
92
+ issues are addressed as part of normal maintenance cycles.
93
+
94
+ Resolved vulnerabilities are disclosed publicly via GitHub Security Advisories
95
+ once a fix is released.
@@ -0,0 +1,4 @@
1
+ import type { DeadlineMiddlewareConfig, DeadlineOptions } from "./types.js";
2
+ export declare function parseConfig(raw: DeadlineOptions | undefined): DeadlineMiddlewareConfig;
3
+
4
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"mappings":"AAIA,cAAc,0BAA0B,uBAAuB;AAE/D,OAAO,iBAAS,YAAY,KAAK,8BAA8B","names":[],"sources":["src/config.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport { flushBufferMs } from \"./types.js\";\nimport type { DeadlineMiddlewareConfig, DeadlineOptions } from \"./types.js\";\n\nexport function parseConfig(raw: DeadlineOptions | undefined): DeadlineMiddlewareConfig {\n const buffer = raw?.flushBufferMs ?? 1000;\n if (buffer < 0) {\n throw new TypeError(`flushBufferMs option must be non-negative, received: ${buffer}`);\n }\n return {\n flushBufferMs: flushBufferMs(buffer),\n telemetryEnabled: raw?.telemetryEnabled ?? true,\n };\n}\n"]}
package/dist/config.js ADDED
@@ -0,0 +1,15 @@
1
+ // SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors
2
+ // SPDX-License-Identifier: MIT
3
+ import { flushBufferMs } from "./types.js";
4
+ export function parseConfig(raw) {
5
+ const buffer = raw?.flushBufferMs ?? 1e3;
6
+ if (buffer < 0) {
7
+ throw new TypeError(`flushBufferMs option must be non-negative, received: ${buffer}`);
8
+ }
9
+ return {
10
+ flushBufferMs: flushBufferMs(buffer),
11
+ telemetryEnabled: raw?.telemetryEnabled ?? true
12
+ };
13
+ }
14
+
15
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"mappings":";;AAGA,SAAS,qBAAqB;AAG9B,OAAO,SAAS,YAAY,KAA4D;CACtF,MAAM,SAAS,KAAK,iBAAiB;CACrC,IAAI,SAAS,GAAG;EACd,MAAM,IAAI,UAAU,wDAAwD,QAAQ;CACtF;CACA,OAAO;EACL,eAAe,cAAc,MAAM;EACnC,kBAAkB,KAAK,oBAAoB;CAC7C;AACF","names":[],"sources":["src/config.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport { flushBufferMs } from \"./types.js\";\nimport type { DeadlineMiddlewareConfig, DeadlineOptions } from \"./types.js\";\n\nexport function parseConfig(raw: DeadlineOptions | undefined): DeadlineMiddlewareConfig {\n const buffer = raw?.flushBufferMs ?? 1000;\n if (buffer < 0) {\n throw new TypeError(`flushBufferMs option must be non-negative, received: ${buffer}`);\n }\n return {\n flushBufferMs: flushBufferMs(buffer),\n telemetryEnabled: raw?.telemetryEnabled ?? true,\n };\n}\n"]}
@@ -0,0 +1,7 @@
1
+ export interface LambdaContextLike {
2
+ getRemainingTimeInMillis?: () => number;
3
+ }
4
+ export declare function run<T>(context: LambdaContextLike | null | undefined, fn: () => T): T;
5
+ export declare function getRemainingTimeInMillis(): number | undefined;
6
+
7
+ //# sourceMappingURL=context-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"mappings":"AAKA,iBAAiB,kBAAkB;CACjC;AACF;AAWA,OAAO,iBAAS,IAAI,GAAG,SAAS,sCAAsC,UAAU,IAAI;AAKpF,OAAO,iBAAS","names":[],"sources":["src/context-store.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nexport interface LambdaContextLike {\n getRemainingTimeInMillis?: () => number;\n}\n\n// Sentinel allows AsyncLocalStorage.run() to accept null/undefined context\n// without throwing, while the accessor can distinguish \"no context stored\"\n// from \"context present but missing the method\".\nconst NO_CONTEXT: unique symbol = Symbol(\"no-context\");\n\ntype StoreValue = LambdaContextLike | typeof NO_CONTEXT;\n\nconst contextStorage = new AsyncLocalStorage<StoreValue>();\n\nexport function run<T>(context: LambdaContextLike | null | undefined, fn: () => T): T {\n const value: StoreValue = context ?? NO_CONTEXT;\n return contextStorage.run(value, fn);\n}\n\nexport function getRemainingTimeInMillis(): number | undefined {\n const store = contextStorage.getStore();\n\n if (store === undefined || store === NO_CONTEXT) {\n return undefined;\n }\n\n if (typeof store.getRemainingTimeInMillis !== \"function\") {\n return undefined;\n }\n\n return store.getRemainingTimeInMillis();\n}\n"]}
@@ -0,0 +1,24 @@
1
+ // SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors
2
+ // SPDX-License-Identifier: MIT
3
+ import { AsyncLocalStorage } from "node:async_hooks";
4
+ // Sentinel allows AsyncLocalStorage.run() to accept null/undefined context
5
+ // without throwing, while the accessor can distinguish "no context stored"
6
+ // from "context present but missing the method".
7
+ const NO_CONTEXT = Symbol("no-context");
8
+ const contextStorage = new AsyncLocalStorage();
9
+ export function run(context, fn) {
10
+ const value = context ?? NO_CONTEXT;
11
+ return contextStorage.run(value, fn);
12
+ }
13
+ export function getRemainingTimeInMillis() {
14
+ const store = contextStorage.getStore();
15
+ if (store === undefined || store === NO_CONTEXT) {
16
+ return undefined;
17
+ }
18
+ if (typeof store.getRemainingTimeInMillis !== "function") {
19
+ return undefined;
20
+ }
21
+ return store.getRemainingTimeInMillis();
22
+ }
23
+
24
+ //# sourceMappingURL=context-store.js.map
@@ -0,0 +1 @@
1
+ {"mappings":";;AAGA,SAAS,yBAAyB;;;;AASlC,MAAM,aAA4B,OAAO,YAAY;AAIrD,MAAM,iBAAiB,IAAI,kBAA8B;AAEzD,OAAO,SAAS,IAAO,SAA+C,IAAgB;CACpF,MAAM,QAAoB,WAAW;CACrC,OAAO,eAAe,IAAI,OAAO,EAAE;AACrC;AAEA,OAAO,SAAS,2BAA+C;CAC7D,MAAM,QAAQ,eAAe,SAAS;CAEtC,IAAI,UAAU,aAAa,UAAU,YAAY;EAC/C,OAAO;CACT;CAEA,IAAI,OAAO,MAAM,6BAA6B,YAAY;EACxD,OAAO;CACT;CAEA,OAAO,MAAM,yBAAyB;AACxC","names":[],"sources":["src/context-store.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nexport interface LambdaContextLike {\n getRemainingTimeInMillis?: () => number;\n}\n\n// Sentinel allows AsyncLocalStorage.run() to accept null/undefined context\n// without throwing, while the accessor can distinguish \"no context stored\"\n// from \"context present but missing the method\".\nconst NO_CONTEXT: unique symbol = Symbol(\"no-context\");\n\ntype StoreValue = LambdaContextLike | typeof NO_CONTEXT;\n\nconst contextStorage = new AsyncLocalStorage<StoreValue>();\n\nexport function run<T>(context: LambdaContextLike | null | undefined, fn: () => T): T {\n const value: StoreValue = context ?? NO_CONTEXT;\n return contextStorage.run(value, fn);\n}\n\nexport function getRemainingTimeInMillis(): number | undefined {\n const store = contextStorage.getStore();\n\n if (store === undefined || store === NO_CONTEXT) {\n return undefined;\n }\n\n if (typeof store.getRemainingTimeInMillis !== \"function\") {\n return undefined;\n }\n\n return store.getRemainingTimeInMillis();\n}\n"]}
@@ -0,0 +1,17 @@
1
+ import type { FlushBufferMs, Milliseconds } from "./types.js";
2
+ interface DeadlineExceededInit {
3
+ readonly deadlineMs: Milliseconds;
4
+ readonly flushBufferMs: FlushBufferMs;
5
+ readonly remainingMs: Milliseconds;
6
+ }
7
+ export declare class DeadlineExceededError extends Error {
8
+ override readonly name = "DeadlineExceededError";
9
+ readonly deadlineMs: Milliseconds;
10
+ readonly flushBufferMs: FlushBufferMs;
11
+ readonly remainingMs: Milliseconds;
12
+ constructor(init: DeadlineExceededInit);
13
+ }
14
+ export declare function isDeadlineExceeded(error: unknown): error is DeadlineExceededError;
15
+ export {};
16
+
17
+ //# sourceMappingURL=error.d.ts.map
@@ -0,0 +1 @@
1
+ {"mappings":"AAGA,cAAc,eAAe,oBAAoB;UAEvC,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,iBAAS,mBAAmB,iBAAiB,SAAS","names":[],"sources":["src/error.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport type { FlushBufferMs, Milliseconds } from \"./types.js\";\n\ninterface DeadlineExceededInit {\n readonly deadlineMs: Milliseconds;\n readonly flushBufferMs: FlushBufferMs;\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: FlushBufferMs;\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 function 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 ADDED
@@ -0,0 +1,21 @@
1
+ export class DeadlineExceededError extends Error {
2
+ name = "DeadlineExceededError";
3
+ deadlineMs;
4
+ flushBufferMs;
5
+ remainingMs;
6
+ constructor(init) {
7
+ super(`Request deadline exceeded: ${init.deadlineMs}ms deadline (${init.flushBufferMs}ms flush buffer)`);
8
+ this.deadlineMs = init.deadlineMs;
9
+ this.flushBufferMs = init.flushBufferMs;
10
+ this.remainingMs = init.remainingMs;
11
+ }
12
+ }
13
+ // Structural check rather than instanceof — works across module boundaries
14
+ // and serialization boundaries where prototype chain may be broken.
15
+ export function isDeadlineExceeded(error) {
16
+ if (error === null || error === undefined) return false;
17
+ if (typeof error !== "object") return false;
18
+ return error.name === "DeadlineExceededError";
19
+ }
20
+
21
+ //# sourceMappingURL=error.js.map
@@ -0,0 +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,SAAS,mBAAmB,OAAgD;CACjF,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: 2024 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport type { FlushBufferMs, Milliseconds } from \"./types.js\";\n\ninterface DeadlineExceededInit {\n readonly deadlineMs: Milliseconds;\n readonly flushBufferMs: FlushBufferMs;\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: FlushBufferMs;\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 function 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"]}
@@ -0,0 +1,13 @@
1
+ import type { LambdaContextLike } from "./context-store.js";
2
+ import type { DeadlineOptions } from "./types.js";
3
+ type AsyncHandler<
4
+ TEvent,
5
+ TResult
6
+ > = (event: TEvent, context: LambdaContextLike) => Promise<TResult>;
7
+ export declare function withLambdaDeadline<
8
+ TEvent,
9
+ TResult
10
+ >(handler: AsyncHandler<TEvent, TResult>, _options?: DeadlineOptions): AsyncHandler<TEvent, TResult>;
11
+ export {};
12
+
13
+ //# sourceMappingURL=handler-wrapper.d.ts.map
@@ -0,0 +1 @@
1
+ {"mappings":"AAIA,cAAc,yBAAyB;AACvC,cAAc,uBAAuB;KAEhC;CAAa;CAAQ;KACxB,OAAO,QACP,SAAS,sBACN,QAAQ;AAEb,OAAO,iBAAS;CAAmB;CAAQ;EACzC,SAAS,aAAa,QAAQ,UAC9B,WAAW,kBACV,aAAa,QAAQ","names":[],"sources":["src/handler-wrapper.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport { run } from \"./context-store.js\";\nimport type { LambdaContextLike } from \"./context-store.js\";\nimport type { DeadlineOptions } from \"./types.js\";\n\ntype AsyncHandler<TEvent, TResult> = (\n event: TEvent,\n context: LambdaContextLike,\n) => Promise<TResult>;\n\nexport function withLambdaDeadline<TEvent, TResult>(\n handler: AsyncHandler<TEvent, TResult>,\n _options?: DeadlineOptions,\n): AsyncHandler<TEvent, TResult> {\n return async (event: TEvent, context: LambdaContextLike): Promise<TResult> =>\n run(context, async () => handler(event, context));\n}\n"]}
@@ -0,0 +1,8 @@
1
+ // SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors
2
+ // SPDX-License-Identifier: MIT
3
+ import { run } from "./context-store.js";
4
+ export function withLambdaDeadline(handler, _options) {
5
+ return async (event, context) => run(context, async () => handler(event, context));
6
+ }
7
+
8
+ //# sourceMappingURL=handler-wrapper.js.map
@@ -0,0 +1 @@
1
+ {"mappings":";;AAGA,SAAS,WAAW;AASpB,OAAO,SAAS,mBACd,SACA,UAC+B;CAC/B,OAAO,OAAO,OAAe,YAC3B,IAAI,SAAS,YAAY,QAAQ,OAAO,OAAO,CAAC;AACpD","names":[],"sources":["src/handler-wrapper.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport { run } from \"./context-store.js\";\nimport type { LambdaContextLike } from \"./context-store.js\";\nimport type { DeadlineOptions } from \"./types.js\";\n\ntype AsyncHandler<TEvent, TResult> = (\n event: TEvent,\n context: LambdaContextLike,\n) => Promise<TResult>;\n\nexport function withLambdaDeadline<TEvent, TResult>(\n handler: AsyncHandler<TEvent, TResult>,\n _options?: DeadlineOptions,\n): AsyncHandler<TEvent, TResult> {\n return async (event: TEvent, context: LambdaContextLike): Promise<TResult> =>\n run(context, async () => handler(event, context));\n}\n"]}
@@ -0,0 +1,8 @@
1
+ export { withLambdaDeadline } from "./handler-wrapper.js";
2
+ export { deadlineMiddleware, withDeadline } from "./registration.js";
3
+ export { DeadlineExceededError, isDeadlineExceeded } from "./error.js";
4
+ export { getRemainingTimeInMillis } from "./context-store.js";
5
+ export type { Milliseconds, FlushBufferMs, RequestDeadlineMs, DeadlineComputation, DeadlineMiddlewareConfig, DeadlineOptions } from "./types.js";
6
+ export type { LambdaContextLike } from "./context-store.js";
7
+
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"mappings":"AAGA,SAAS,0BAA0B;AACnC,SAAS,oBAAoB,oBAAoB;AACjD,SAAS,uBAAuB,0BAA0B;AAC1D,SAAS,gCAAgC;AAEzC,cACE,cACA,eACA,mBACA,qBACA,0BACA,uBACK;AAEP,cAAc,yBAAyB","names":[],"sources":["src/index.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nexport { withLambdaDeadline } from \"./handler-wrapper.js\";\nexport { deadlineMiddleware, withDeadline } from \"./registration.js\";\nexport { DeadlineExceededError, isDeadlineExceeded } from \"./error.js\";\nexport { getRemainingTimeInMillis } from \"./context-store.js\";\n\nexport type {\n Milliseconds,\n FlushBufferMs,\n RequestDeadlineMs,\n DeadlineComputation,\n DeadlineMiddlewareConfig,\n DeadlineOptions,\n} from \"./types.js\";\n\nexport type { LambdaContextLike } from \"./context-store.js\";\n"]}
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ // SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors
2
+ // SPDX-License-Identifier: MIT
3
+ export { withLambdaDeadline } from "./handler-wrapper.js";
4
+ export { deadlineMiddleware, withDeadline } from "./registration.js";
5
+ export { DeadlineExceededError, isDeadlineExceeded } from "./error.js";
6
+ export { getRemainingTimeInMillis } from "./context-store.js";
7
+
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"mappings":";;AAGA,SAAS,0BAA0B;AACnC,SAAS,oBAAoB,oBAAoB;AACjD,SAAS,uBAAuB,0BAA0B;AAC1D,SAAS,gCAAgC","names":[],"sources":["src/index.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nexport { withLambdaDeadline } from \"./handler-wrapper.js\";\nexport { deadlineMiddleware, withDeadline } from \"./registration.js\";\nexport { DeadlineExceededError, isDeadlineExceeded } from \"./error.js\";\nexport { getRemainingTimeInMillis } from \"./context-store.js\";\n\nexport type {\n Milliseconds,\n FlushBufferMs,\n RequestDeadlineMs,\n DeadlineComputation,\n DeadlineMiddlewareConfig,\n DeadlineOptions,\n} from \"./types.js\";\n\nexport type { LambdaContextLike } from \"./context-store.js\";\n"]}
@@ -0,0 +1,12 @@
1
+ import type { DeadlineComputation, DeadlineMiddlewareConfig, RequestDeadlineMs } from "./types.js";
2
+ import type { FinalizeRequestMiddleware } from "@smithy/types";
3
+ export declare function computeDeadline(config: DeadlineMiddlewareConfig): DeadlineComputation;
4
+ export interface DeadlineTimer {
5
+ readonly controller: AbortController;
6
+ [Symbol.dispose]: () => void;
7
+ }
8
+ export declare function createDeadlineTimer(deadlineMs: RequestDeadlineMs, config: DeadlineMiddlewareConfig): DeadlineTimer;
9
+ export declare function composeSignals(existing: AbortSignal | undefined, deadline: AbortSignal): AbortSignal;
10
+ export declare function deadlineMiddlewareHandler(config: DeadlineMiddlewareConfig): FinalizeRequestMiddleware<object, object>;
11
+
12
+ //# sourceMappingURL=middleware.d.ts.map
@@ -0,0 +1 @@
1
+ {"mappings":"AAKA,cAAc,qBAAqB,0BAA0B,yBAAyB;AAGtF,cAIE,iCAEK;AAEP,OAAO,iBAAS,gBAAgB,QAAQ,2BAA2B;AAqBnE,iBAAiB,cAAc;UACpB,YAAY;EACpB,OAAO;AACV;AAEA,OAAO,iBAAS,oBACd,YAAY,mBACZ,QAAQ,2BACP;AAmBH,OAAO,iBAAS,eACd,UAAU,yBACV,UAAU,cACT;AAKH,OAAO,iBAAS,0BACd,QAAQ,2BACP","names":[],"sources":["src/middleware.ts"],"version":3,"sourcesContent":["// SPDX-FileCopyrightText: 2024 lambda-deadline-middleware contributors\n// SPDX-License-Identifier: MIT\n\nimport { getRemainingTimeInMillis } from \"./context-store.js\";\nimport { DeadlineExceededError } from \"./error.js\";\nimport type { DeadlineComputation, DeadlineMiddlewareConfig, RequestDeadlineMs } from \"./types.js\";\nimport { milliseconds } from \"./types.js\";\n\nimport type {\n FinalizeHandler,\n FinalizeHandlerArguments,\n FinalizeHandlerOutput,\n FinalizeRequestMiddleware,\n HandlerExecutionContext,\n} from \"@smithy/types\";\n\nexport function computeDeadline(config: DeadlineMiddlewareConfig): DeadlineComputation {\n const remaining = getRemainingTimeInMillis();\n\n if (remaining === undefined) {\n return { kind: \"no-context\" };\n }\n\n const deadline = remaining - config.flushBufferMs;\n\n if (deadline <= 0) {\n return {\n kind: \"insufficient-time\",\n remaining: milliseconds(remaining),\n buffer: config.flushBufferMs,\n };\n }\n\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- branded narrowing: deadline is validated > 0 above\n return { kind: \"deadline\", value: deadline as RequestDeadlineMs };\n}\n\nexport interface DeadlineTimer {\n readonly controller: AbortController;\n [Symbol.dispose]: () => void;\n}\n\nexport function createDeadlineTimer(\n deadlineMs: RequestDeadlineMs,\n config: DeadlineMiddlewareConfig,\n): DeadlineTimer {\n const controller = new AbortController();\n const remaining = milliseconds(deadlineMs + config.flushBufferMs);\n const error = new DeadlineExceededError({\n deadlineMs: milliseconds(deadlineMs),\n flushBufferMs: config.flushBufferMs,\n remainingMs: remaining,\n });\n const timeoutId = setTimeout(() => {\n controller.abort(error);\n }, deadlineMs);\n return {\n controller,\n [Symbol.dispose]() {\n clearTimeout(timeoutId);\n },\n };\n}\n\nexport function 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 function deadlineMiddlewareHandler(\n config: DeadlineMiddlewareConfig,\n): FinalizeRequestMiddleware<object, object> {\n return (\n next: FinalizeHandler<object, object>,\n _context: HandlerExecutionContext,\n ): FinalizeHandler<object, object> =>\n // oxlint-disable-next-line typescript/consistent-return -- switch is exhaustive over DeadlineComputation discriminated union\n async (args: FinalizeHandlerArguments<object>): Promise<FinalizeHandlerOutput<object>> => {\n const computation = computeDeadline(config);\n\n switch (computation.kind) {\n case \"no-context\":\n return next(args);\n\n case \"insufficient-time\":\n throw new DeadlineExceededError({\n deadlineMs: milliseconds(0),\n flushBufferMs: computation.buffer,\n remainingMs: computation.remaining,\n });\n\n case \"deadline\": {\n using timer = createDeadlineTimer(computation.value, config);\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, timer.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 };\n}\n"]}