safe-timeouts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aryan Tiwari
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
+ # safe-timeout
2
+
3
+ **Deadline-based timeouts for async Node.js code with AbortSignal support.**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/safe-timeout)](https://www.npmjs.com/package/safe-timeout)
6
+ [![npm downloads](https://img.shields.io/npm/dm/safe-timeout)](https://www.npmjs.com/package/safe-timeout)
7
+ [![license](https://img.shields.io/npm/l/safe-timeout)](./LICENSE)
8
+ [![types](https://img.shields.io/npm/types/safe-timeout)](https://www.npmjs.com/package/safe-timeout)
9
+ [![node](https://img.shields.io/node/v/safe-timeout)](https://nodejs.org)
10
+
11
+ Promise-based deadline enforcement for async code in Node.js. `safe-timeout` helps you apply a **single execution deadline** across async functions, services, and external calls using standard `AbortSignal` semantics.
12
+
13
+ ---
14
+
15
+ ## Why this exists
16
+
17
+ In real backend systems, timeouts are **end-to-end**, not per-function:
18
+
19
+ * An HTTP request has a deadline
20
+ * That deadline must apply across DB calls, service logic, and external APIs
21
+ * Nested functions should **not accidentally extend** the available time
22
+
23
+ Most timeout utilities fail here because they:
24
+
25
+ * don’t propagate context
26
+ * don’t compose across nested calls
27
+ * don’t integrate with `AbortSignal`
28
+
29
+ `safe-timeout` solves this correctly.
30
+
31
+ ---
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ npm install safe-timeout
37
+ ```
38
+
39
+ Node.js >= 16 is required.
40
+
41
+ ---
42
+
43
+ ## Basic usage
44
+
45
+ ```ts
46
+ import { withTimeout, TimeoutError } from "safe-timeout";
47
+ import axios from "axios";
48
+
49
+ try {
50
+ const result = await withTimeout(2000, async (signal) => {
51
+ const res = await axios.get("https://api.example.com/users", { signal });
52
+ return res.data;
53
+ });
54
+
55
+ console.log(result);
56
+ } catch (err) {
57
+ if (err instanceof TimeoutError) {
58
+ console.error("Request timed out");
59
+ }
60
+ }
61
+ ```
62
+
63
+ What happens:
64
+
65
+ * A 2s deadline is created
66
+ * An `AbortController` is started internally
67
+ * If the deadline is exceeded:
68
+
69
+ * the promise rejects with `TimeoutError`
70
+ * the `AbortSignal` is aborted
71
+ * Axios cancels the HTTP request
72
+
73
+ ---
74
+
75
+ ## Nested timeouts (key feature)
76
+
77
+ Deadlines **propagate and compose automatically**.
78
+
79
+ ```ts
80
+ await withTimeout(3000, async () => {
81
+ await serviceA(); // uses part of the budget
82
+
83
+ await withTimeout(5000, async () => {
84
+ await serviceB(); // still limited by the original 3s
85
+ });
86
+ });
87
+ ```
88
+
89
+ The inner timeout **cannot extend** the outer deadline.
90
+
91
+ This makes time budgets safe and deterministic.
92
+
93
+ ---
94
+
95
+ ## Using with services (multiple layers)
96
+
97
+ ```ts
98
+ import axios from "axios";
99
+
100
+ await withTimeout(2000, async (signal) => {
101
+ await controller(signal);
102
+ });
103
+
104
+ async function controller(signal) {
105
+ await serviceA(signal);
106
+ }
107
+
108
+ async function serviceA(signal) {
109
+ await serviceB(signal);
110
+ }
111
+
112
+ async function serviceB(signal) {
113
+ const res = await axios.get("/users", { signal });
114
+ return res.data;
115
+ }
116
+ ```
117
+
118
+ All functions share the same deadline by passing the same `AbortSignal` down the call chain.
119
+
120
+ ---
121
+
122
+ ## Abort-aware vs non-abort-aware operations
123
+
124
+ ### Abort-aware APIs (cancel immediately)
125
+
126
+ These stop execution as soon as the deadline is exceeded:
127
+
128
+ * `fetch` (Node 18+)
129
+ * `axios` (with `{ signal }`)
130
+ * `fs/promises` (partial)
131
+ * `stream.pipeline`
132
+ * `timers/promises`
133
+
134
+ Example:
135
+
136
+ ```ts
137
+ // GET
138
+ await axios.get(url, { signal }); // 👈 AbortSignal goes here
139
+ // POST
140
+ await axios.post(
141
+ url,
142
+ { name: "Aryan", role: "admin" },
143
+ {
144
+ signal, // 👈 AbortSignal goes here
145
+ headers: {
146
+ "Content-Type": "application/json",
147
+ Authorization: "Bearer YOUR_TOKEN",
148
+ },
149
+ })
150
+
151
+ ```
152
+
153
+ ### Non-abort-aware operations (cooperative)
154
+
155
+ These **cannot be forcibly stopped**:
156
+
157
+ * `setTimeout` / sleep
158
+ * Sequelize queries
159
+ * CPU-bound loops
160
+ * legacy libraries
161
+
162
+ For these, `safe-timeout`:
163
+
164
+ * stops waiting
165
+ * rejects the outer promise
166
+ * allows you to guard further logic
167
+
168
+ ---
169
+
170
+ ## Non-abort-aware operations and control flow
171
+
172
+ JavaScript cannot forcibly stop non-abort-aware operations (like `setTimeout`, Sequelize queries, or CPU-bound work).
173
+
174
+ When such operations exceed the deadline:
175
+
176
+ * `safe-timeout` rejects the outer promise
177
+ * abort-aware APIs are cancelled automatically
178
+ * JavaScript execution resumes only when the pending operation completes
179
+
180
+ To keep control flow predictable:
181
+
182
+ * prefer calling abort-aware APIs (Axios, fetch, streams) after non-abort-aware work
183
+ * abort-aware APIs will throw immediately if the deadline has already been exceeded
184
+
185
+ This design avoids hidden global checks while remaining honest about JavaScript limitations.
186
+
187
+ ---
188
+
189
+ ## Axios integration
190
+
191
+ `safe-timeout` works with Axios by passing the provided `AbortSignal` to the request.
192
+
193
+ ```ts
194
+ import axios from "axios";
195
+ import { withTimeout } from "safe-timeout";
196
+
197
+ await withTimeout(2000, async (signal) => {
198
+ const res = await axios.get("/users", { signal });
199
+ return res.data;
200
+ });
201
+ ```
202
+
203
+ Axios is abort-aware:
204
+
205
+ * if the deadline is exceeded before the request starts, Axios throws immediately
206
+ * if the deadline is exceeded while the request is in flight, Axios cancels the request
207
+
208
+ This explicit integration keeps cancellation predictable and avoids hidden behavior.
209
+
210
+ ---
211
+
212
+ ## What `safe-timeout` does NOT do
213
+
214
+ It is important to be explicit about limitations:
215
+
216
+ * ❌ It cannot forcibly stop JavaScript execution
217
+ * ❌ It cannot cancel non-abort-aware libraries
218
+ * ❌ It cannot stop CPU-bound loops
219
+ * ❌ It does not replace DB-level timeouts
220
+
221
+ This matches the realities of Node.js and modern async runtimes.
222
+
223
+ ---
224
+
225
+ ## How this differs from `setTimeout`
226
+
227
+ | Feature | setTimeout | safe-timeout |
228
+ | ------------------- | ---------- | ------------ |
229
+ | End-to-end deadline | ❌ | ✅ |
230
+ | Nested composition | ❌ | ✅ |
231
+ | AbortSignal support | ❌ | ✅ |
232
+ | Context propagation | ❌ | ✅ |
233
+ | Concurrency-safe | ❌ | ✅ |
234
+
235
+ `setTimeout` works locally. `safe-timeout` works across your entire async call graph.
236
+
237
+ ---
238
+
239
+ ## API
240
+
241
+ ### `withTimeout(ms, fn)`
242
+
243
+ Runs an async function with a deadline.
244
+
245
+ ```ts
246
+ withTimeout<T>(ms: number, fn: (signal: AbortSignal) => Promise<T>): Promise<T>
247
+ ```
248
+
249
+ Rejects with `TimeoutError` when the deadline is exceeded.
250
+
251
+ ---
252
+
253
+ ### `TimeoutError`
254
+
255
+ Error thrown when the deadline is exceeded.
256
+
257
+ ```ts
258
+ instanceof TimeoutError === true
259
+ ```
260
+
261
+ ---
262
+
263
+ ## When to use this
264
+
265
+ Use `safe-timeout` when:
266
+
267
+ * you want request-level deadlines
268
+ * you call multiple async services
269
+ * you rely on Axios, fetch, or streams
270
+ * you want correct nested timeout behavior
271
+
272
+ Do **not** use it as a replacement for DB-level query timeouts.
273
+
274
+ ---
275
+
276
+ ## License
277
+
278
+ MIT
@@ -0,0 +1,16 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ declare function withTimeout<T>(ms: number, fn: (signal: AbortSignal) => Promise<T>): Promise<T>;
4
+
5
+ type TimeoutContext = {
6
+ deadline: number;
7
+ controller: AbortController;
8
+ };
9
+
10
+ declare const timeoutContext: AsyncLocalStorage<TimeoutContext>;
11
+
12
+ declare class TimeoutError extends Error {
13
+ constructor(message?: string);
14
+ }
15
+
16
+ export { TimeoutError, timeoutContext, withTimeout };
@@ -0,0 +1,16 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ declare function withTimeout<T>(ms: number, fn: (signal: AbortSignal) => Promise<T>): Promise<T>;
4
+
5
+ type TimeoutContext = {
6
+ deadline: number;
7
+ controller: AbortController;
8
+ };
9
+
10
+ declare const timeoutContext: AsyncLocalStorage<TimeoutContext>;
11
+
12
+ declare class TimeoutError extends Error {
13
+ constructor(message?: string);
14
+ }
15
+
16
+ export { TimeoutError, timeoutContext, withTimeout };
package/dist/index.js ADDED
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ TimeoutError: () => TimeoutError,
24
+ timeoutContext: () => timeoutContext,
25
+ withTimeout: () => withTimeout
26
+ });
27
+ module.exports = __toCommonJS(index_exports);
28
+
29
+ // src/context.ts
30
+ var import_node_async_hooks = require("async_hooks");
31
+ var timeoutContext = new import_node_async_hooks.AsyncLocalStorage();
32
+
33
+ // src/errors.ts
34
+ var TimeoutError = class extends Error {
35
+ constructor(message = "Operation timed out") {
36
+ super(message);
37
+ this.name = "TimeoutError";
38
+ }
39
+ };
40
+
41
+ // src/withTimeout.ts
42
+ async function withTimeout(ms, fn) {
43
+ const parent = timeoutContext.getStore();
44
+ const now = Date.now();
45
+ const deadline = parent ? Math.min(parent.deadline, now + ms) : now + ms;
46
+ if (deadline <= now) {
47
+ throw new TimeoutError();
48
+ }
49
+ const controller = new AbortController();
50
+ const remaining = deadline - now;
51
+ const timer = setTimeout(() => {
52
+ controller.abort();
53
+ }, remaining);
54
+ try {
55
+ return await timeoutContext.run(
56
+ { deadline, controller },
57
+ () => Promise.race([
58
+ fn(controller.signal),
59
+ new Promise((_, reject) => {
60
+ controller.signal.addEventListener(
61
+ "abort",
62
+ () => reject(new TimeoutError()),
63
+ { once: true }
64
+ );
65
+ })
66
+ ])
67
+ );
68
+ } finally {
69
+ clearTimeout(timer);
70
+ }
71
+ }
72
+ // Annotate the CommonJS export names for ESM import in node:
73
+ 0 && (module.exports = {
74
+ TimeoutError,
75
+ timeoutContext,
76
+ withTimeout
77
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,48 @@
1
+ // src/context.ts
2
+ import { AsyncLocalStorage } from "async_hooks";
3
+ var timeoutContext = new AsyncLocalStorage();
4
+
5
+ // src/errors.ts
6
+ var TimeoutError = class extends Error {
7
+ constructor(message = "Operation timed out") {
8
+ super(message);
9
+ this.name = "TimeoutError";
10
+ }
11
+ };
12
+
13
+ // src/withTimeout.ts
14
+ async function withTimeout(ms, fn) {
15
+ const parent = timeoutContext.getStore();
16
+ const now = Date.now();
17
+ const deadline = parent ? Math.min(parent.deadline, now + ms) : now + ms;
18
+ if (deadline <= now) {
19
+ throw new TimeoutError();
20
+ }
21
+ const controller = new AbortController();
22
+ const remaining = deadline - now;
23
+ const timer = setTimeout(() => {
24
+ controller.abort();
25
+ }, remaining);
26
+ try {
27
+ return await timeoutContext.run(
28
+ { deadline, controller },
29
+ () => Promise.race([
30
+ fn(controller.signal),
31
+ new Promise((_, reject) => {
32
+ controller.signal.addEventListener(
33
+ "abort",
34
+ () => reject(new TimeoutError()),
35
+ { once: true }
36
+ );
37
+ })
38
+ ])
39
+ );
40
+ } finally {
41
+ clearTimeout(timer);
42
+ }
43
+ }
44
+ export {
45
+ TimeoutError,
46
+ timeoutContext,
47
+ withTimeout
48
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "safe-timeouts",
3
+ "version": "0.1.0",
4
+ "description": "Deadline-based timeouts for async code in Node.js. Enforce end-to-end execution deadlines with automatic propagation and AbortSignal support.",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsup"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/yetanotheraryan/safe-timeout.git"
14
+ },
15
+ "keywords": [
16
+ "timeout",
17
+ "deadline",
18
+ "abortcontroller",
19
+ "abortsignal",
20
+ "async",
21
+ "nodejs",
22
+ "axios",
23
+ "fetch",
24
+ "structured-concurrency",
25
+ "async-hooks"
26
+ ],
27
+ "author": "Aryan Tiwari",
28
+ "license": "MIT",
29
+ "bugs": {
30
+ "url": "https://github.com/yetanotheraryan/safe-timeout/issues"
31
+ },
32
+ "homepage": "https://github.com/yetanotheraryan/safe-timeout#readme",
33
+ "devDependencies": {
34
+ "@types/node": "^25.0.9",
35
+ "tsup": "^8.5.1",
36
+ "typescript": "^5.9.3"
37
+ }
38
+ }