timefence 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 Tung Tran
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,86 @@
1
+ # timefence
2
+
3
+ > Put a **time limit** on any promise — with real **`AbortSignal` cancellation** and a composable **deadline** signal. **Zero dependencies**.
4
+
5
+ [![CI](https://github.com/trananhtung/timefence/actions/workflows/ci.yml/badge.svg)](https://github.com/trananhtung/timefence/actions/workflows/ci.yml)
6
+ [![npm version](https://img.shields.io/npm/v/timefence.svg)](https://www.npmjs.com/package/timefence)
7
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/timefence)](https://bundlephobia.com/package/timefence)
8
+ [![types](https://img.shields.io/npm/types/timefence.svg)](https://www.npmjs.com/package/timefence)
9
+ [![license](https://img.shields.io/npm/l/timefence.svg)](./LICENSE)
10
+
11
+ `Promise.race([op, timer])` "times out" — but the slow operation keeps running in
12
+ the background, holding a socket open and burning quota. `timefence` gives the
13
+ operation an `AbortSignal` that fires on timeout, so the underlying `fetch` (or any
14
+ cooperative work) is actually **cancelled**, not just ignored.
15
+
16
+ ```ts
17
+ import { withTimeout } from "timefence";
18
+
19
+ // On timeout, the fetch is aborted — not left dangling.
20
+ const res = await withTimeout((signal) => fetch(url, { signal }), 5000);
21
+ ```
22
+
23
+ ## Why timefence?
24
+
25
+ - **Real cancellation.** The function form receives an `AbortSignal` that fires on
26
+ timeout *or* when your own `signal` aborts — wire it to `fetch`/streams.
27
+ - **Fallback or throw.** Reject with a typed `TimeoutError`, or resolve with a
28
+ `fallback` value when you'd rather degrade than fail.
29
+ - **Composable deadlines.** `deadline(ms, signal?)` returns an `AbortSignal` that
30
+ trips after `ms` (or earlier if your signal aborts) — like `AbortSignal.timeout`
31
+ but composable, with an `unref`'d timer so it won't keep Node alive.
32
+ - **Zero dependencies**, ESM + CJS + types.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ npm install timefence
38
+ # or: pnpm add timefence / yarn add timefence / bun add timefence
39
+ ```
40
+
41
+ ## API
42
+
43
+ ### `withTimeout(input, ms, options?) → Promise<T>`
44
+
45
+ `input` is a promise, or `(signal) => Promise` (preferred — enables cancellation).
46
+
47
+ ```ts
48
+ await withTimeout(slowPromise, 1000); // throws TimeoutError
49
+ await withTimeout((s) => fetch(url, { signal: s }), 1000); // aborts the fetch
50
+ await withTimeout(loadFresh(), 800, { fallback: () => cached });
51
+ ```
52
+
53
+ | Option | Type | Default | Description |
54
+ | ---------- | ----------------------- | ------- | ---------------------------------------------------- |
55
+ | `message` | `string` | — | Message for the thrown `TimeoutError`. |
56
+ | `signal` | `AbortSignal` | — | Cancel early from outside (rejects with its reason). |
57
+ | `fallback` | `() => T \| Promise<T>` | — | Resolve with this on timeout instead of throwing. |
58
+
59
+ `ms = Infinity` disables the timeout while still honoring `signal`.
60
+
61
+ ### `deadline(ms, signal?) → AbortSignal`
62
+
63
+ ```ts
64
+ const signal = deadline(10_000, userSignal);
65
+ await fetch(url, { signal }); // aborts after 10s, or when userSignal does
66
+ ```
67
+
68
+ ### `TimeoutError` / `isTimeoutError(err)`
69
+
70
+ ```ts
71
+ try {
72
+ await withTimeout(op, 1000);
73
+ } catch (err) {
74
+ if (isTimeoutError(err)) retryOrDegrade();
75
+ else throw err;
76
+ }
77
+ ```
78
+
79
+ ## Pairs well with
80
+
81
+ - [`retryfn`](https://www.npmjs.com/package/retryfn) — retry an operation that timed out.
82
+ - [`runpool`](https://www.npmjs.com/package/runpool) / [`ratebucket`](https://www.npmjs.com/package/ratebucket) — bound concurrency and rate; `timefence` bounds *time*.
83
+
84
+ ## License
85
+
86
+ [MIT](./LICENSE) © Tung Tran
package/dist/index.cjs ADDED
@@ -0,0 +1,127 @@
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 src_exports = {};
22
+ __export(src_exports, {
23
+ TimeoutError: () => TimeoutError,
24
+ deadline: () => deadline,
25
+ isTimeoutError: () => isTimeoutError,
26
+ withTimeout: () => withTimeout
27
+ });
28
+ module.exports = __toCommonJS(src_exports);
29
+
30
+ // src/timeout.ts
31
+ var TimeoutError = class extends Error {
32
+ constructor(message = "Operation timed out") {
33
+ super(message);
34
+ this.name = "TimeoutError";
35
+ }
36
+ };
37
+ function isTimeoutError(err) {
38
+ return err instanceof Error && err.name === "TimeoutError";
39
+ }
40
+ function abortError(signal) {
41
+ return signal?.reason ?? new DOMException("This operation was aborted", "AbortError");
42
+ }
43
+ function withTimeout(input, ms, options = {}) {
44
+ const { signal: external, message, fallback } = options;
45
+ return new Promise((resolve, reject) => {
46
+ const controller = new AbortController();
47
+ let timer;
48
+ let settled = false;
49
+ const cleanup = () => {
50
+ if (timer !== void 0) clearTimeout(timer);
51
+ external?.removeEventListener("abort", onExternalAbort);
52
+ };
53
+ const settle = (action) => {
54
+ if (settled) return;
55
+ settled = true;
56
+ cleanup();
57
+ action();
58
+ };
59
+ function onExternalAbort() {
60
+ controller.abort(external?.reason);
61
+ settle(() => reject(abortError(external)));
62
+ }
63
+ if (external) {
64
+ if (external.aborted) {
65
+ controller.abort(external.reason);
66
+ return settle(() => reject(abortError(external)));
67
+ }
68
+ external.addEventListener("abort", onExternalAbort, { once: true });
69
+ }
70
+ const onTimeout = () => {
71
+ const err = new TimeoutError(message ?? `Operation timed out after ${ms} ms`);
72
+ controller.abort(err);
73
+ if (fallback) {
74
+ Promise.resolve().then(fallback).then(
75
+ (value) => settle(() => resolve(value)),
76
+ (e) => settle(() => reject(e))
77
+ );
78
+ } else {
79
+ settle(() => reject(err));
80
+ }
81
+ };
82
+ if (Number.isFinite(ms) && ms >= 0) {
83
+ timer = setTimeout(onTimeout, ms);
84
+ }
85
+ let source;
86
+ if (typeof input === "function") {
87
+ try {
88
+ source = Promise.resolve(input(controller.signal));
89
+ } catch (err) {
90
+ source = Promise.reject(err);
91
+ }
92
+ } else {
93
+ source = Promise.resolve(input);
94
+ }
95
+ source.then(
96
+ (value) => settle(() => resolve(value)),
97
+ (err) => settle(() => reject(err))
98
+ );
99
+ });
100
+ }
101
+ function deadline(ms, signal) {
102
+ const controller = new AbortController();
103
+ if (signal) {
104
+ if (signal.aborted) {
105
+ controller.abort(signal.reason);
106
+ return controller.signal;
107
+ }
108
+ signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
109
+ }
110
+ if (Number.isFinite(ms) && ms >= 0) {
111
+ const timer = setTimeout(
112
+ () => controller.abort(new TimeoutError(`Deadline of ${ms} ms exceeded`)),
113
+ ms
114
+ );
115
+ timer.unref?.();
116
+ controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
117
+ }
118
+ return controller.signal;
119
+ }
120
+ // Annotate the CommonJS export names for ESM import in node:
121
+ 0 && (module.exports = {
122
+ TimeoutError,
123
+ deadline,
124
+ isTimeoutError,
125
+ withTimeout
126
+ });
127
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/timeout.ts"],"sourcesContent":["/**\n * timefence — put a time limit on any promise, with real AbortSignal cancellation\n * and a composable deadline signal. Zero dependencies.\n *\n * @packageDocumentation\n */\n\nexport {\n withTimeout,\n deadline,\n TimeoutError,\n isTimeoutError,\n type WithTimeoutOptions,\n} from \"./timeout.js\";\n","/** Error thrown when an operation exceeds its time budget. */\nexport class TimeoutError extends Error {\n constructor(message = \"Operation timed out\") {\n super(message);\n this.name = \"TimeoutError\";\n }\n}\n\n/** `true` if `err` is a {@link TimeoutError}. */\nexport function isTimeoutError(err: unknown): err is TimeoutError {\n return err instanceof Error && err.name === \"TimeoutError\";\n}\n\nfunction abortError(signal?: AbortSignal): unknown {\n return signal?.reason ?? new DOMException(\"This operation was aborted\", \"AbortError\");\n}\n\n/** Options for {@link withTimeout}. */\nexport interface WithTimeoutOptions<T> {\n /** Message for the thrown {@link TimeoutError}. */\n message?: string;\n /** External signal that cancels the operation early. */\n signal?: AbortSignal;\n /** If provided, resolve with this instead of rejecting when the time runs out. */\n fallback?: () => T | Promise<T>;\n}\n\n/**\n * Run an operation with a time limit.\n *\n * `input` may be a promise, or a function receiving an `AbortSignal` that fires\n * when the deadline (or an external `signal`) is reached — so cooperative work\n * (`fetch`, etc.) is actually cancelled, not just abandoned.\n *\n * @example\n * ```ts\n * // Function form: the fetch is aborted on timeout.\n * const res = await withTimeout((signal) => fetch(url, { signal }), 5000);\n *\n * // Fallback instead of throwing:\n * const data = await withTimeout(loadFresh(), 800, { fallback: () => cached });\n * ```\n *\n * @param input - A promise, or `(signal) => Promise`.\n * @param ms - Timeout in ms. `Infinity` disables the timeout (abort still works).\n * @throws {TimeoutError} on timeout (unless `fallback` is given); rejects if the\n * external `signal` aborts.\n */\nexport function withTimeout<T>(\n input: Promise<T> | ((signal: AbortSignal) => Promise<T> | T),\n ms: number,\n options: WithTimeoutOptions<T> = {},\n): Promise<T> {\n const { signal: external, message, fallback } = options;\n\n return new Promise<T>((resolve, reject) => {\n const controller = new AbortController();\n let timer: ReturnType<typeof setTimeout> | undefined;\n let settled = false;\n\n const cleanup = () => {\n if (timer !== undefined) clearTimeout(timer);\n external?.removeEventListener(\"abort\", onExternalAbort);\n };\n const settle = (action: () => void): void => {\n if (settled) return;\n settled = true;\n cleanup();\n action();\n };\n\n function onExternalAbort(): void {\n controller.abort(external?.reason);\n settle(() => reject(abortError(external)));\n }\n\n if (external) {\n if (external.aborted) {\n controller.abort(external.reason);\n return settle(() => reject(abortError(external)));\n }\n external.addEventListener(\"abort\", onExternalAbort, { once: true });\n }\n\n const onTimeout = (): void => {\n const err = new TimeoutError(message ?? `Operation timed out after ${ms} ms`);\n controller.abort(err);\n if (fallback) {\n Promise.resolve()\n .then(fallback)\n .then(\n (value) => settle(() => resolve(value)),\n (e) => settle(() => reject(e)),\n );\n } else {\n settle(() => reject(err));\n }\n };\n\n if (Number.isFinite(ms) && ms >= 0) {\n timer = setTimeout(onTimeout, ms);\n }\n\n let source: Promise<T>;\n if (typeof input === \"function\") {\n try {\n source = Promise.resolve((input as (s: AbortSignal) => Promise<T> | T)(controller.signal));\n } catch (err) {\n source = Promise.reject(err);\n }\n } else {\n source = Promise.resolve(input);\n }\n\n source.then(\n (value) => settle(() => resolve(value)),\n (err) => settle(() => reject(err)),\n );\n });\n}\n\n/**\n * Create an `AbortSignal` that aborts after `ms` (with a {@link TimeoutError}\n * reason), optionally composed with an external `signal` that aborts it earlier.\n *\n * Like `AbortSignal.timeout`, but composable and with a typed reason. The\n * internal timer is `unref`'d so it never keeps a Node process alive.\n *\n * @example\n * ```ts\n * const res = await fetch(url, { signal: deadline(10_000, userSignal) });\n * ```\n */\nexport function deadline(ms: number, signal?: AbortSignal): AbortSignal {\n const controller = new AbortController();\n\n if (signal) {\n if (signal.aborted) {\n controller.abort(signal.reason);\n return controller.signal;\n }\n signal.addEventListener(\"abort\", () => controller.abort(signal.reason), { once: true });\n }\n\n if (Number.isFinite(ms) && ms >= 0) {\n const timer = setTimeout(\n () => controller.abort(new TimeoutError(`Deadline of ${ms} ms exceeded`)),\n ms,\n );\n (timer as { unref?: () => void }).unref?.();\n controller.signal.addEventListener(\"abort\", () => clearTimeout(timer), { once: true });\n }\n\n return controller.signal;\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,52 @@
1
+ /** Error thrown when an operation exceeds its time budget. */
2
+ declare class TimeoutError extends Error {
3
+ constructor(message?: string);
4
+ }
5
+ /** `true` if `err` is a {@link TimeoutError}. */
6
+ declare function isTimeoutError(err: unknown): err is TimeoutError;
7
+ /** Options for {@link withTimeout}. */
8
+ interface WithTimeoutOptions<T> {
9
+ /** Message for the thrown {@link TimeoutError}. */
10
+ message?: string;
11
+ /** External signal that cancels the operation early. */
12
+ signal?: AbortSignal;
13
+ /** If provided, resolve with this instead of rejecting when the time runs out. */
14
+ fallback?: () => T | Promise<T>;
15
+ }
16
+ /**
17
+ * Run an operation with a time limit.
18
+ *
19
+ * `input` may be a promise, or a function receiving an `AbortSignal` that fires
20
+ * when the deadline (or an external `signal`) is reached — so cooperative work
21
+ * (`fetch`, etc.) is actually cancelled, not just abandoned.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * // Function form: the fetch is aborted on timeout.
26
+ * const res = await withTimeout((signal) => fetch(url, { signal }), 5000);
27
+ *
28
+ * // Fallback instead of throwing:
29
+ * const data = await withTimeout(loadFresh(), 800, { fallback: () => cached });
30
+ * ```
31
+ *
32
+ * @param input - A promise, or `(signal) => Promise`.
33
+ * @param ms - Timeout in ms. `Infinity` disables the timeout (abort still works).
34
+ * @throws {TimeoutError} on timeout (unless `fallback` is given); rejects if the
35
+ * external `signal` aborts.
36
+ */
37
+ declare function withTimeout<T>(input: Promise<T> | ((signal: AbortSignal) => Promise<T> | T), ms: number, options?: WithTimeoutOptions<T>): Promise<T>;
38
+ /**
39
+ * Create an `AbortSignal` that aborts after `ms` (with a {@link TimeoutError}
40
+ * reason), optionally composed with an external `signal` that aborts it earlier.
41
+ *
42
+ * Like `AbortSignal.timeout`, but composable and with a typed reason. The
43
+ * internal timer is `unref`'d so it never keeps a Node process alive.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * const res = await fetch(url, { signal: deadline(10_000, userSignal) });
48
+ * ```
49
+ */
50
+ declare function deadline(ms: number, signal?: AbortSignal): AbortSignal;
51
+
52
+ export { TimeoutError, type WithTimeoutOptions, deadline, isTimeoutError, withTimeout };
@@ -0,0 +1,52 @@
1
+ /** Error thrown when an operation exceeds its time budget. */
2
+ declare class TimeoutError extends Error {
3
+ constructor(message?: string);
4
+ }
5
+ /** `true` if `err` is a {@link TimeoutError}. */
6
+ declare function isTimeoutError(err: unknown): err is TimeoutError;
7
+ /** Options for {@link withTimeout}. */
8
+ interface WithTimeoutOptions<T> {
9
+ /** Message for the thrown {@link TimeoutError}. */
10
+ message?: string;
11
+ /** External signal that cancels the operation early. */
12
+ signal?: AbortSignal;
13
+ /** If provided, resolve with this instead of rejecting when the time runs out. */
14
+ fallback?: () => T | Promise<T>;
15
+ }
16
+ /**
17
+ * Run an operation with a time limit.
18
+ *
19
+ * `input` may be a promise, or a function receiving an `AbortSignal` that fires
20
+ * when the deadline (or an external `signal`) is reached — so cooperative work
21
+ * (`fetch`, etc.) is actually cancelled, not just abandoned.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * // Function form: the fetch is aborted on timeout.
26
+ * const res = await withTimeout((signal) => fetch(url, { signal }), 5000);
27
+ *
28
+ * // Fallback instead of throwing:
29
+ * const data = await withTimeout(loadFresh(), 800, { fallback: () => cached });
30
+ * ```
31
+ *
32
+ * @param input - A promise, or `(signal) => Promise`.
33
+ * @param ms - Timeout in ms. `Infinity` disables the timeout (abort still works).
34
+ * @throws {TimeoutError} on timeout (unless `fallback` is given); rejects if the
35
+ * external `signal` aborts.
36
+ */
37
+ declare function withTimeout<T>(input: Promise<T> | ((signal: AbortSignal) => Promise<T> | T), ms: number, options?: WithTimeoutOptions<T>): Promise<T>;
38
+ /**
39
+ * Create an `AbortSignal` that aborts after `ms` (with a {@link TimeoutError}
40
+ * reason), optionally composed with an external `signal` that aborts it earlier.
41
+ *
42
+ * Like `AbortSignal.timeout`, but composable and with a typed reason. The
43
+ * internal timer is `unref`'d so it never keeps a Node process alive.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * const res = await fetch(url, { signal: deadline(10_000, userSignal) });
48
+ * ```
49
+ */
50
+ declare function deadline(ms: number, signal?: AbortSignal): AbortSignal;
51
+
52
+ export { TimeoutError, type WithTimeoutOptions, deadline, isTimeoutError, withTimeout };
package/dist/index.js ADDED
@@ -0,0 +1,97 @@
1
+ // src/timeout.ts
2
+ var TimeoutError = class extends Error {
3
+ constructor(message = "Operation timed out") {
4
+ super(message);
5
+ this.name = "TimeoutError";
6
+ }
7
+ };
8
+ function isTimeoutError(err) {
9
+ return err instanceof Error && err.name === "TimeoutError";
10
+ }
11
+ function abortError(signal) {
12
+ return signal?.reason ?? new DOMException("This operation was aborted", "AbortError");
13
+ }
14
+ function withTimeout(input, ms, options = {}) {
15
+ const { signal: external, message, fallback } = options;
16
+ return new Promise((resolve, reject) => {
17
+ const controller = new AbortController();
18
+ let timer;
19
+ let settled = false;
20
+ const cleanup = () => {
21
+ if (timer !== void 0) clearTimeout(timer);
22
+ external?.removeEventListener("abort", onExternalAbort);
23
+ };
24
+ const settle = (action) => {
25
+ if (settled) return;
26
+ settled = true;
27
+ cleanup();
28
+ action();
29
+ };
30
+ function onExternalAbort() {
31
+ controller.abort(external?.reason);
32
+ settle(() => reject(abortError(external)));
33
+ }
34
+ if (external) {
35
+ if (external.aborted) {
36
+ controller.abort(external.reason);
37
+ return settle(() => reject(abortError(external)));
38
+ }
39
+ external.addEventListener("abort", onExternalAbort, { once: true });
40
+ }
41
+ const onTimeout = () => {
42
+ const err = new TimeoutError(message ?? `Operation timed out after ${ms} ms`);
43
+ controller.abort(err);
44
+ if (fallback) {
45
+ Promise.resolve().then(fallback).then(
46
+ (value) => settle(() => resolve(value)),
47
+ (e) => settle(() => reject(e))
48
+ );
49
+ } else {
50
+ settle(() => reject(err));
51
+ }
52
+ };
53
+ if (Number.isFinite(ms) && ms >= 0) {
54
+ timer = setTimeout(onTimeout, ms);
55
+ }
56
+ let source;
57
+ if (typeof input === "function") {
58
+ try {
59
+ source = Promise.resolve(input(controller.signal));
60
+ } catch (err) {
61
+ source = Promise.reject(err);
62
+ }
63
+ } else {
64
+ source = Promise.resolve(input);
65
+ }
66
+ source.then(
67
+ (value) => settle(() => resolve(value)),
68
+ (err) => settle(() => reject(err))
69
+ );
70
+ });
71
+ }
72
+ function deadline(ms, signal) {
73
+ const controller = new AbortController();
74
+ if (signal) {
75
+ if (signal.aborted) {
76
+ controller.abort(signal.reason);
77
+ return controller.signal;
78
+ }
79
+ signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
80
+ }
81
+ if (Number.isFinite(ms) && ms >= 0) {
82
+ const timer = setTimeout(
83
+ () => controller.abort(new TimeoutError(`Deadline of ${ms} ms exceeded`)),
84
+ ms
85
+ );
86
+ timer.unref?.();
87
+ controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
88
+ }
89
+ return controller.signal;
90
+ }
91
+ export {
92
+ TimeoutError,
93
+ deadline,
94
+ isTimeoutError,
95
+ withTimeout
96
+ };
97
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/timeout.ts"],"sourcesContent":["/** Error thrown when an operation exceeds its time budget. */\nexport class TimeoutError extends Error {\n constructor(message = \"Operation timed out\") {\n super(message);\n this.name = \"TimeoutError\";\n }\n}\n\n/** `true` if `err` is a {@link TimeoutError}. */\nexport function isTimeoutError(err: unknown): err is TimeoutError {\n return err instanceof Error && err.name === \"TimeoutError\";\n}\n\nfunction abortError(signal?: AbortSignal): unknown {\n return signal?.reason ?? new DOMException(\"This operation was aborted\", \"AbortError\");\n}\n\n/** Options for {@link withTimeout}. */\nexport interface WithTimeoutOptions<T> {\n /** Message for the thrown {@link TimeoutError}. */\n message?: string;\n /** External signal that cancels the operation early. */\n signal?: AbortSignal;\n /** If provided, resolve with this instead of rejecting when the time runs out. */\n fallback?: () => T | Promise<T>;\n}\n\n/**\n * Run an operation with a time limit.\n *\n * `input` may be a promise, or a function receiving an `AbortSignal` that fires\n * when the deadline (or an external `signal`) is reached — so cooperative work\n * (`fetch`, etc.) is actually cancelled, not just abandoned.\n *\n * @example\n * ```ts\n * // Function form: the fetch is aborted on timeout.\n * const res = await withTimeout((signal) => fetch(url, { signal }), 5000);\n *\n * // Fallback instead of throwing:\n * const data = await withTimeout(loadFresh(), 800, { fallback: () => cached });\n * ```\n *\n * @param input - A promise, or `(signal) => Promise`.\n * @param ms - Timeout in ms. `Infinity` disables the timeout (abort still works).\n * @throws {TimeoutError} on timeout (unless `fallback` is given); rejects if the\n * external `signal` aborts.\n */\nexport function withTimeout<T>(\n input: Promise<T> | ((signal: AbortSignal) => Promise<T> | T),\n ms: number,\n options: WithTimeoutOptions<T> = {},\n): Promise<T> {\n const { signal: external, message, fallback } = options;\n\n return new Promise<T>((resolve, reject) => {\n const controller = new AbortController();\n let timer: ReturnType<typeof setTimeout> | undefined;\n let settled = false;\n\n const cleanup = () => {\n if (timer !== undefined) clearTimeout(timer);\n external?.removeEventListener(\"abort\", onExternalAbort);\n };\n const settle = (action: () => void): void => {\n if (settled) return;\n settled = true;\n cleanup();\n action();\n };\n\n function onExternalAbort(): void {\n controller.abort(external?.reason);\n settle(() => reject(abortError(external)));\n }\n\n if (external) {\n if (external.aborted) {\n controller.abort(external.reason);\n return settle(() => reject(abortError(external)));\n }\n external.addEventListener(\"abort\", onExternalAbort, { once: true });\n }\n\n const onTimeout = (): void => {\n const err = new TimeoutError(message ?? `Operation timed out after ${ms} ms`);\n controller.abort(err);\n if (fallback) {\n Promise.resolve()\n .then(fallback)\n .then(\n (value) => settle(() => resolve(value)),\n (e) => settle(() => reject(e)),\n );\n } else {\n settle(() => reject(err));\n }\n };\n\n if (Number.isFinite(ms) && ms >= 0) {\n timer = setTimeout(onTimeout, ms);\n }\n\n let source: Promise<T>;\n if (typeof input === \"function\") {\n try {\n source = Promise.resolve((input as (s: AbortSignal) => Promise<T> | T)(controller.signal));\n } catch (err) {\n source = Promise.reject(err);\n }\n } else {\n source = Promise.resolve(input);\n }\n\n source.then(\n (value) => settle(() => resolve(value)),\n (err) => settle(() => reject(err)),\n );\n });\n}\n\n/**\n * Create an `AbortSignal` that aborts after `ms` (with a {@link TimeoutError}\n * reason), optionally composed with an external `signal` that aborts it earlier.\n *\n * Like `AbortSignal.timeout`, but composable and with a typed reason. The\n * internal timer is `unref`'d so it never keeps a Node process alive.\n *\n * @example\n * ```ts\n * const res = await fetch(url, { signal: deadline(10_000, userSignal) });\n * ```\n */\nexport function deadline(ms: number, signal?: AbortSignal): AbortSignal {\n const controller = new AbortController();\n\n if (signal) {\n if (signal.aborted) {\n controller.abort(signal.reason);\n return controller.signal;\n }\n signal.addEventListener(\"abort\", () => controller.abort(signal.reason), { once: true });\n }\n\n if (Number.isFinite(ms) && ms >= 0) {\n const timer = setTimeout(\n () => controller.abort(new TimeoutError(`Deadline of ${ms} ms exceeded`)),\n ms,\n );\n (timer as { unref?: () => void }).unref?.();\n controller.signal.addEventListener(\"abort\", () => clearTimeout(timer), { once: true });\n }\n\n return controller.signal;\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "timefence",
3
+ "version": "0.1.0",
4
+ "description": "Put a time limit on any promise, with real AbortSignal cancellation and a composable deadline signal. Zero dependencies.",
5
+ "keywords": [
6
+ "timeout",
7
+ "deadline",
8
+ "p-timeout",
9
+ "abortsignal",
10
+ "abort",
11
+ "promise",
12
+ "cancel",
13
+ "async",
14
+ "race",
15
+ "resilience",
16
+ "zero-dependency"
17
+ ],
18
+ "license": "MIT",
19
+ "author": "Tung Tran (https://github.com/trananhtung)",
20
+ "homepage": "https://github.com/trananhtung/timefence#readme",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/trananhtung/timefence.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/trananhtung/timefence/issues"
27
+ },
28
+ "type": "module",
29
+ "main": "./dist/index.cjs",
30
+ "module": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js",
36
+ "require": "./dist/index.cjs"
37
+ }
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "engines": {
45
+ "node": ">=18"
46
+ },
47
+ "scripts": {
48
+ "build": "tsup",
49
+ "dev": "tsup --watch",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest",
52
+ "typecheck": "tsc --noEmit",
53
+ "prepublishOnly": "npm run build"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^20.17.10",
57
+ "tsup": "^8.3.5",
58
+ "typescript": "^5.7.2",
59
+ "vitest": "^2.1.8"
60
+ },
61
+ "sideEffects": false,
62
+ "publishConfig": {
63
+ "access": "public"
64
+ }
65
+ }