@orkestrel/timeout 0.0.4 → 0.0.5

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.
@@ -1,32 +1,168 @@
1
- import { isString } from "@orkestrel/contract";
1
+ import { ContractError, isFiniteNumber, isInteger, isRecord, isString, preview } from "@orkestrel/contract";
2
+ //#region src/core/constants.ts
3
+ /**
4
+ * Largest timeout duration accepted by the package, in milliseconds.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * createTimeout({ ms: MAX_TIMEOUT_MS })
9
+ * ```
10
+ */
11
+ var MAX_TIMEOUT_MS = 2147483647;
12
+ //#endregion
13
+ //#region src/core/validators.ts
14
+ /**
15
+ * Determine whether a value is an accepted timeout duration.
16
+ *
17
+ * @param value - Unknown duration candidate
18
+ * @returns `true` only for an integer in the inclusive timeout range
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * isTimeoutDuration(0) // true
23
+ * isTimeoutDuration(1.5) // false
24
+ * ```
25
+ */
26
+ function isTimeoutDuration(value) {
27
+ return isFiniteNumber(value) && isInteger(value) && value >= 0 && value <= 2147483647;
28
+ }
29
+ /**
30
+ * Determine whether a value is a genuine native `AbortSignal`.
31
+ *
32
+ * @remarks
33
+ * The intrinsic `aborted` getter performs the native brand check. The boundary
34
+ * contains revoked proxies and hostile receivers, returning `false` instead of
35
+ * allowing their errors to escape.
36
+ *
37
+ * @param value - Unknown signal candidate
38
+ * @returns `true` only when the native `AbortSignal` getter accepts the value
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * isTimeoutSignal(new AbortController().signal) // true
43
+ * isTimeoutSignal({ aborted: false }) // false
44
+ * ```
45
+ */
46
+ function isTimeoutSignal(value) {
47
+ try {
48
+ const descriptor = Object.getOwnPropertyDescriptor(AbortSignal.prototype, "aborted");
49
+ if (descriptor?.get === void 0) return false;
50
+ return typeof Reflect.apply(descriptor.get, value, []) === "boolean";
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+ //#endregion
56
+ //#region src/core/helpers.ts
57
+ /**
58
+ * Validate and normalize timeout construction options.
59
+ *
60
+ * @remarks
61
+ * Each property is read exactly once before validation. The returned object is
62
+ * a fresh copy and omits absent optional properties. No timer, controller, or
63
+ * listener lifecycle begins at this boundary.
64
+ *
65
+ * @param options - Potentially untrusted timeout options
66
+ * @returns A fresh validated `TimeoutOptions` object
67
+ * @throws {@link import('@orkestrel/contract').ContractError} When the input
68
+ * does not satisfy `TimeoutOptions`
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * const options = validateTimeoutOptions({ ms: 5_000 })
73
+ * ```
74
+ */
75
+ function validateTimeoutOptions(options) {
76
+ if (!isRecord(options)) throw new ContractError("Timeout: options must be a plain record", {
77
+ code: "bound",
78
+ context: {
79
+ path: ["options"],
80
+ limit: "plain record",
81
+ received: preview(options)
82
+ }
83
+ });
84
+ let id;
85
+ let ms;
86
+ let signal;
87
+ try {
88
+ id = options.id;
89
+ ms = options.ms;
90
+ signal = options.signal;
91
+ } catch (cause) {
92
+ throw new ContractError("Timeout: options could not be read", {
93
+ code: "bound",
94
+ context: {
95
+ path: ["options"],
96
+ limit: "readable plain record",
97
+ received: preview(options)
98
+ },
99
+ cause
100
+ });
101
+ }
102
+ if (id !== void 0 && !isString(id)) throw new ContractError("Timeout: id must be a string when defined", {
103
+ code: "literal",
104
+ context: {
105
+ path: ["options", "id"],
106
+ limit: "string or undefined",
107
+ received: preview(id)
108
+ }
109
+ });
110
+ if (!isTimeoutDuration(ms)) throw new ContractError("Timeout: ms must be an integer in the supported range", {
111
+ code: "range",
112
+ context: {
113
+ path: ["options", "ms"],
114
+ limit: `integer in [0, ${MAX_TIMEOUT_MS}]`,
115
+ received: preview(ms)
116
+ }
117
+ });
118
+ if (signal !== void 0 && !isTimeoutSignal(signal)) throw new ContractError("Timeout: signal must be a native AbortSignal when defined", {
119
+ code: "placement",
120
+ context: {
121
+ path: ["options", "signal"],
122
+ limit: "native AbortSignal or undefined",
123
+ received: preview(signal)
124
+ }
125
+ });
126
+ if (id !== void 0 && signal !== void 0) return {
127
+ id,
128
+ ms,
129
+ signal
130
+ };
131
+ if (id !== void 0) return {
132
+ id,
133
+ ms
134
+ };
135
+ if (signal !== void 0) return {
136
+ ms,
137
+ signal
138
+ };
139
+ return { ms };
140
+ }
141
+ //#endregion
2
142
  //#region src/core/Timeout.ts
3
143
  /**
4
- * A deadline handle — a controllable `setTimeout` wrapper that exposes an
5
- * `AbortSignal` which fires when the timeout expires, for racing against work.
144
+ * A controllable deadline whose native `AbortSignal` aborts when it expires.
6
145
  *
7
146
  * @remarks
8
- * - **Deadline signal.** `start()` arms a `setTimeout` for `ms`; on expiry it
9
- * flips `expired` and aborts the controller, firing `signal`. Race `signal`
10
- * against work to bound how long it may run.
11
- * - **Controllable.** `clear()` cancels a pending expiry WITHOUT firing `signal`,
12
- * and resets `expired` back to `false` (the §10 reset) — a cleared timeout
13
- * reports `expired === false` and a non-aborted `signal`.
14
- * - **Reusable.** Re-`start()` after an expiry swaps in a fresh controller and
15
- * resets `expired`, so `signal` / `expired` reflect the new run — the handle is
16
- * reusable across deadlines without re-construction.
17
- * - **Parent linking.** A parent `signal` does NOT expire this timeout — it
18
- * CLEARS it: the parent listener is attached only while a timer is armed (added
19
- * on `start()`, removed on expiry or `clear()`), so a parent abort during the
20
- * timing window cancels the pending expiry and a later `start()` is a no-op once
21
- * the parent has aborted. An idle handle holds no parent listener.
22
- * - **Event-free.** A pure functional primitive — no Emitter, no events.
147
+ * `start()` arms or replaces the current deadline. Expiry sets `expired` and
148
+ * aborts the current signal once. `clear()` cancels without aborting and resets
149
+ * `expired`. Starting or clearing after expiry installs a fresh signal. A parent
150
+ * abort deliberately clears the timeout without aborting its signal; once the
151
+ * parent has aborted, later starts are inert.
152
+ *
153
+ * Construction validates the complete JavaScript boundary before creating an
154
+ * `AbortController`. A malformed or unreadable options record throws a
155
+ * `bound`-coded {@link import('@orkestrel/contract').ContractError}; an invalid
156
+ * `id`, `ms`, or `signal` uses `literal`, `range`, or `placement`, respectively.
23
157
  *
24
158
  * @example
25
159
  * ```ts
160
+ * import { Timeout } from '@orkestrel/timeout'
161
+ *
26
162
  * const timeout = new Timeout({ ms: 5_000 })
27
163
  * timeout.start()
28
164
  * timeout.signal.addEventListener('abort', () => giveUp(), { once: true })
29
- * timeout.clear() // cancels the deadline before it fires
165
+ * timeout.clear()
30
166
  * ```
31
167
  */
32
168
  var Timeout = class {
@@ -34,21 +170,22 @@ var Timeout = class {
34
170
  ms;
35
171
  #parent;
36
172
  #listener;
37
- #controller = new AbortController();
173
+ #controller;
38
174
  #handle;
39
175
  #linked = false;
40
- #expired = false;
41
176
  constructor(options) {
42
- this.id = isString(options.id) ? options.id : crypto.randomUUID();
43
- this.ms = options.ms;
44
- this.#parent = options.signal;
177
+ const input = validateTimeoutOptions(options);
178
+ this.id = input.id === void 0 ? crypto.randomUUID() : input.id;
179
+ this.ms = input.ms;
180
+ this.#parent = input.signal;
181
+ this.#controller = new AbortController();
45
182
  this.#listener = this.clear.bind(this);
46
183
  }
47
184
  get signal() {
48
185
  return this.#controller.signal;
49
186
  }
50
187
  get expired() {
51
- return this.#expired;
188
+ return this.#controller.signal.aborted;
52
189
  }
53
190
  start() {
54
191
  if (this.#parent?.aborted === true) return;
@@ -57,7 +194,6 @@ var Timeout = class {
57
194
  this.#handle = void 0;
58
195
  }
59
196
  this.#detach();
60
- this.#expired = false;
61
197
  if (this.#controller.signal.aborted) this.#controller = new AbortController();
62
198
  if (this.#parent !== void 0) {
63
199
  this.#parent.addEventListener("abort", this.#listener, { once: true });
@@ -65,9 +201,8 @@ var Timeout = class {
65
201
  }
66
202
  this.#handle = setTimeout(() => {
67
203
  this.#handle = void 0;
68
- this.#expired = true;
69
- this.#controller.abort();
70
204
  this.#detach();
205
+ this.#controller.abort();
71
206
  }, this.ms);
72
207
  }
73
208
  clear() {
@@ -76,7 +211,6 @@ var Timeout = class {
76
211
  this.#handle = void 0;
77
212
  }
78
213
  this.#detach();
79
- this.#expired = false;
80
214
  if (this.#controller.signal.aborted) this.#controller = new AbortController();
81
215
  }
82
216
  #detach() {
@@ -89,44 +223,37 @@ var Timeout = class {
89
223
  //#endregion
90
224
  //#region src/core/factories.ts
91
225
  /**
92
- * Create a deadline handle — a controllable `setTimeout` wrapper that exposes an
93
- * `AbortSignal` which fires when the timeout expires, for racing against work.
226
+ * Create a controllable deadline whose native signal aborts on expiry.
94
227
  *
95
228
  * @remarks
96
- * Call `start()` to arm the deadline for `ms` milliseconds; on expiry the handle's
97
- * `signal` fires and `expired` flips `true`. Call `clear()` to cancel a pending
98
- * deadline without firing. When `options.signal` is given, a parent abort CLEARS
99
- * the timeout (it never expires) rather than firing it. Pass `options.id` to label
100
- * the handle for tracing, or let it default to a random UUID. `ms` must be a
101
- * non-negative finite number; the host `setTimeout` clamps a negative or `NaN`
102
- * value to roughly `0` (firing on the next macrotask) rather than throwing.
103
- *
104
- * @param options - `ms` (the deadline in milliseconds, a non-negative finite
105
- * number), an optional `id` (a trace label; defaults to a random UUID), and an
106
- * optional parent `signal` whose abort clears the timeout
107
- * @returns A working {@link TimeoutInterface}
229
+ * `options.ms` must be an integer from `0` through `2_147_483_647`, inclusive.
230
+ * A parent `options.signal` clears an armed timeout without aborting the
231
+ * timeout's own signal. Omitted `options.id` values generate a random UUID.
232
+ * Malformed or unreadable options use contract code `bound`; invalid `id`,
233
+ * `ms`, and `signal` values use `literal`, `range`, and `placement`.
234
+ *
235
+ * @param options - Validated deadline, optional trace label, and optional native parent signal
236
+ * @returns A reusable timeout handle
237
+ * @throws {@link import('@orkestrel/contract').ContractError} When the
238
+ * JavaScript input does not satisfy `TimeoutOptions`
108
239
  *
109
240
  * @example
110
241
  * ```ts
111
- * import { createTimeout } from '@src/core'
242
+ * import { createTimeout } from '@orkestrel/timeout'
112
243
  *
113
244
  * const timeout = createTimeout({ ms: 5_000 })
114
245
  * timeout.start()
115
- * const result = await Promise.race([
116
- * work(),
117
- * new Promise((_, reject) =>
118
- * timeout.signal.addEventListener('abort', () => reject(new Error('timed out')), {
119
- * once: true,
120
- * }),
121
- * ),
122
- * ])
123
- * timeout.clear() // work finished first — cancel the deadline
246
+ * try {
247
+ * await fetch('/work', { signal: timeout.signal })
248
+ * } finally {
249
+ * timeout.clear()
250
+ * }
124
251
  * ```
125
252
  */
126
253
  function createTimeout(options) {
127
254
  return new Timeout(options);
128
255
  }
129
256
  //#endregion
130
- export { Timeout, createTimeout };
257
+ export { MAX_TIMEOUT_MS, Timeout, createTimeout, isTimeoutDuration, isTimeoutSignal, validateTimeoutOptions };
131
258
 
132
259
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["#parent","#listener","#controller","#expired","#handle","#detach","#linked"],"sources":["../../../src/core/Timeout.ts","../../../src/core/factories.ts"],"sourcesContent":["import type { TimeoutInterface, TimeoutOptions } from './types.js'\nimport { isString } from '@orkestrel/contract'\n\n/**\n * A deadline handle — a controllable `setTimeout` wrapper that exposes an\n * `AbortSignal` which fires when the timeout expires, for racing against work.\n *\n * @remarks\n * - **Deadline signal.** `start()` arms a `setTimeout` for `ms`; on expiry it\n * flips `expired` and aborts the controller, firing `signal`. Race `signal`\n * against work to bound how long it may run.\n * - **Controllable.** `clear()` cancels a pending expiry WITHOUT firing `signal`,\n * and resets `expired` back to `false` (the §10 reset) — a cleared timeout\n * reports `expired === false` and a non-aborted `signal`.\n * - **Reusable.** Re-`start()` after an expiry swaps in a fresh controller and\n * resets `expired`, so `signal` / `expired` reflect the new run — the handle is\n * reusable across deadlines without re-construction.\n * - **Parent linking.** A parent `signal` does NOT expire this timeout — it\n * CLEARS it: the parent listener is attached only while a timer is armed (added\n * on `start()`, removed on expiry or `clear()`), so a parent abort during the\n * timing window cancels the pending expiry and a later `start()` is a no-op once\n * the parent has aborted. An idle handle holds no parent listener.\n * - **Event-free.** A pure functional primitive — no Emitter, no events.\n *\n * @example\n * ```ts\n * const timeout = new Timeout({ ms: 5_000 })\n * timeout.start()\n * timeout.signal.addEventListener('abort', () => giveUp(), { once: true })\n * timeout.clear() // cancels the deadline before it fires\n * ```\n */\nexport class Timeout implements TimeoutInterface {\n\treadonly id: string\n\treadonly ms: number\n\treadonly #parent: AbortSignal | undefined\n\treadonly #listener: () => void\n\t#controller = new AbortController()\n\t#handle: ReturnType<typeof setTimeout> | undefined\n\t#linked = false\n\t#expired = false\n\n\tconstructor(options: TimeoutOptions) {\n\t\t// Construction is the defensive JS boundary — validate here so the\n\t\t// start()/clear() hot paths stay dependency-free.\n\t\tthis.id = isString(options.id) ? options.id : crypto.randomUUID()\n\t\tthis.ms = options.ms\n\t\tthis.#parent = options.signal\n\t\tthis.#listener = this.clear.bind(this)\n\t}\n\n\tget signal(): AbortSignal {\n\t\treturn this.#controller.signal\n\t}\n\n\tget expired(): boolean {\n\t\treturn this.#expired\n\t}\n\n\tstart(): void {\n\t\t// Once the parent has aborted, the deadline must never fire.\n\t\tif (this.#parent?.aborted === true) return\n\t\t// Re-arm cleanly: drop any pending timer + parent listener from a prior run.\n\t\tif (this.#handle !== undefined) {\n\t\t\tclearTimeout(this.#handle)\n\t\t\tthis.#handle = undefined\n\t\t}\n\t\tthis.#detach()\n\t\tthis.#expired = false\n\t\t// Swap in a fresh controller only when the current one has already fired, so a\n\t\t// re-armed run gets a clean `signal` while a listener attached before the FIRST\n\t\t// `start()` (on the construction-time signal) survives to fire on expiry.\n\t\tif (this.#controller.signal.aborted) this.#controller = new AbortController()\n\t\t// Link the parent only for the lifetime of this timer — a parent abort CLEARS\n\t\t// the timeout (so it never expires); the listener is removed when the timer settles.\n\t\tif (this.#parent !== undefined) {\n\t\t\tthis.#parent.addEventListener('abort', this.#listener, { once: true })\n\t\t\tthis.#linked = true\n\t\t}\n\t\tthis.#handle = setTimeout(() => {\n\t\t\tthis.#handle = undefined\n\t\t\tthis.#expired = true\n\t\t\tthis.#controller.abort()\n\t\t\t// The timer fired — drop the parent listener so a later parent abort can no\n\t\t\t// longer reach `clear()` and un-expire this legitimate expiry.\n\t\t\tthis.#detach()\n\t\t}, this.ms)\n\t}\n\n\tclear(): void {\n\t\tif (this.#handle !== undefined) {\n\t\t\tclearTimeout(this.#handle)\n\t\t\tthis.#handle = undefined\n\t\t}\n\t\tthis.#detach()\n\t\tthis.#expired = false\n\t\t// If the controller had already fired, swap a fresh one so a cleared timeout\n\t\t// reports `expired === false` AND a non-aborted `signal`, consistently.\n\t\tif (this.#controller.signal.aborted) this.#controller = new AbortController()\n\t}\n\n\t// Remove the parent-abort listener if one is currently attached.\n\t#detach(): void {\n\t\tif (this.#linked) {\n\t\t\tthis.#parent?.removeEventListener('abort', this.#listener)\n\t\t\tthis.#linked = false\n\t\t}\n\t}\n}\n","import type { TimeoutInterface, TimeoutOptions } from './types.js'\nimport { Timeout } from './Timeout.js'\n\n/**\n * Create a deadline handle — a controllable `setTimeout` wrapper that exposes an\n * `AbortSignal` which fires when the timeout expires, for racing against work.\n *\n * @remarks\n * Call `start()` to arm the deadline for `ms` milliseconds; on expiry the handle's\n * `signal` fires and `expired` flips `true`. Call `clear()` to cancel a pending\n * deadline without firing. When `options.signal` is given, a parent abort CLEARS\n * the timeout (it never expires) rather than firing it. Pass `options.id` to label\n * the handle for tracing, or let it default to a random UUID. `ms` must be a\n * non-negative finite number; the host `setTimeout` clamps a negative or `NaN`\n * value to roughly `0` (firing on the next macrotask) rather than throwing.\n *\n * @param options - `ms` (the deadline in milliseconds, a non-negative finite\n * number), an optional `id` (a trace label; defaults to a random UUID), and an\n * optional parent `signal` whose abort clears the timeout\n * @returns A working {@link TimeoutInterface}\n *\n * @example\n * ```ts\n * import { createTimeout } from '@src/core'\n *\n * const timeout = createTimeout({ ms: 5_000 })\n * timeout.start()\n * const result = await Promise.race([\n * \twork(),\n * \tnew Promise((_, reject) =>\n * \t\ttimeout.signal.addEventListener('abort', () => reject(new Error('timed out')), {\n * \t\t\tonce: true,\n * \t\t}),\n * \t),\n * ])\n * timeout.clear() // work finished first — cancel the deadline\n * ```\n */\nexport function createTimeout(options: TimeoutOptions): TimeoutInterface {\n\treturn new Timeout(options)\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,IAAa,UAAb,MAAiD;CAChD;CACA;CACA;CACA;CACA,cAAc,IAAI,gBAAgB;CAClC;CACA,UAAU;CACV,WAAW;CAEX,YAAY,SAAyB;EAGpC,KAAK,KAAK,SAAS,QAAQ,EAAE,IAAI,QAAQ,KAAK,OAAO,WAAW;EAChE,KAAK,KAAK,QAAQ;EAClB,KAAKA,UAAU,QAAQ;EACvB,KAAKC,YAAY,KAAK,MAAM,KAAK,IAAI;CACtC;CAEA,IAAI,SAAsB;EACzB,OAAO,KAAKC,YAAY;CACzB;CAEA,IAAI,UAAmB;EACtB,OAAO,KAAKC;CACb;CAEA,QAAc;EAEb,IAAI,KAAKH,SAAS,YAAY,MAAM;EAEpC,IAAI,KAAKI,YAAY,KAAA,GAAW;GAC/B,aAAa,KAAKA,OAAO;GACzB,KAAKA,UAAU,KAAA;EAChB;EACA,KAAKC,QAAQ;EACb,KAAKF,WAAW;EAIhB,IAAI,KAAKD,YAAY,OAAO,SAAS,KAAKA,cAAc,IAAI,gBAAgB;EAG5E,IAAI,KAAKF,YAAY,KAAA,GAAW;GAC/B,KAAKA,QAAQ,iBAAiB,SAAS,KAAKC,WAAW,EAAE,MAAM,KAAK,CAAC;GACrE,KAAKK,UAAU;EAChB;EACA,KAAKF,UAAU,iBAAiB;GAC/B,KAAKA,UAAU,KAAA;GACf,KAAKD,WAAW;GAChB,KAAKD,YAAY,MAAM;GAGvB,KAAKG,QAAQ;EACd,GAAG,KAAK,EAAE;CACX;CAEA,QAAc;EACb,IAAI,KAAKD,YAAY,KAAA,GAAW;GAC/B,aAAa,KAAKA,OAAO;GACzB,KAAKA,UAAU,KAAA;EAChB;EACA,KAAKC,QAAQ;EACb,KAAKF,WAAW;EAGhB,IAAI,KAAKD,YAAY,OAAO,SAAS,KAAKA,cAAc,IAAI,gBAAgB;CAC7E;CAGA,UAAgB;EACf,IAAI,KAAKI,SAAS;GACjB,KAAKN,SAAS,oBAAoB,SAAS,KAAKC,SAAS;GACzD,KAAKK,UAAU;EAChB;CACD;AACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACtEA,SAAgB,cAAc,SAA2C;CACxE,OAAO,IAAI,QAAQ,OAAO;AAC3B"}
1
+ {"version":3,"file":"index.js","names":["#parent","#listener","#controller","#handle","#detach","#linked"],"sources":["../../../src/core/constants.ts","../../../src/core/validators.ts","../../../src/core/helpers.ts","../../../src/core/Timeout.ts","../../../src/core/factories.ts"],"sourcesContent":["/**\n * Largest timeout duration accepted by the package, in milliseconds.\n *\n * @example\n * ```ts\n * createTimeout({ ms: MAX_TIMEOUT_MS })\n * ```\n */\nexport const MAX_TIMEOUT_MS = 2_147_483_647\n","import { isFiniteNumber, isInteger } from '@orkestrel/contract'\nimport { MAX_TIMEOUT_MS } from './constants.js'\n\n/**\n * Determine whether a value is an accepted timeout duration.\n *\n * @param value - Unknown duration candidate\n * @returns `true` only for an integer in the inclusive timeout range\n *\n * @example\n * ```ts\n * isTimeoutDuration(0) // true\n * isTimeoutDuration(1.5) // false\n * ```\n */\nexport function isTimeoutDuration(value: unknown): value is number {\n\treturn isFiniteNumber(value) && isInteger(value) && value >= 0 && value <= MAX_TIMEOUT_MS\n}\n\n/**\n * Determine whether a value is a genuine native `AbortSignal`.\n *\n * @remarks\n * The intrinsic `aborted` getter performs the native brand check. The boundary\n * contains revoked proxies and hostile receivers, returning `false` instead of\n * allowing their errors to escape.\n *\n * @param value - Unknown signal candidate\n * @returns `true` only when the native `AbortSignal` getter accepts the value\n *\n * @example\n * ```ts\n * isTimeoutSignal(new AbortController().signal) // true\n * isTimeoutSignal({ aborted: false }) // false\n * ```\n */\nexport function isTimeoutSignal(value: unknown): value is AbortSignal {\n\ttry {\n\t\tconst descriptor = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')\n\t\tif (descriptor?.get === undefined) return false\n\t\treturn typeof Reflect.apply(descriptor.get, value, []) === 'boolean'\n\t} catch {\n\t\treturn false\n\t}\n}\n","import type { TimeoutOptions } from './types.js'\nimport { ContractError, isRecord, isString, preview } from '@orkestrel/contract'\nimport { MAX_TIMEOUT_MS } from './constants.js'\nimport { isTimeoutDuration, isTimeoutSignal } from './validators.js'\n\n/**\n * Validate and normalize timeout construction options.\n *\n * @remarks\n * Each property is read exactly once before validation. The returned object is\n * a fresh copy and omits absent optional properties. No timer, controller, or\n * listener lifecycle begins at this boundary.\n *\n * @param options - Potentially untrusted timeout options\n * @returns A fresh validated `TimeoutOptions` object\n * @throws {@link import('@orkestrel/contract').ContractError} When the input\n * does not satisfy `TimeoutOptions`\n *\n * @example\n * ```ts\n * const options = validateTimeoutOptions({ ms: 5_000 })\n * ```\n */\nexport function validateTimeoutOptions(options: TimeoutOptions): TimeoutOptions {\n\tif (!isRecord(options)) {\n\t\tthrow new ContractError('Timeout: options must be a plain record', {\n\t\t\tcode: 'bound',\n\t\t\tcontext: {\n\t\t\t\tpath: ['options'],\n\t\t\t\tlimit: 'plain record',\n\t\t\t\treceived: preview(options),\n\t\t\t},\n\t\t})\n\t}\n\n\tlet id: TimeoutOptions['id']\n\tlet ms: TimeoutOptions['ms']\n\tlet signal: TimeoutOptions['signal']\n\ttry {\n\t\tid = options.id\n\t\tms = options.ms\n\t\tsignal = options.signal\n\t} catch (cause) {\n\t\tthrow new ContractError('Timeout: options could not be read', {\n\t\t\tcode: 'bound',\n\t\t\tcontext: {\n\t\t\t\tpath: ['options'],\n\t\t\t\tlimit: 'readable plain record',\n\t\t\t\treceived: preview(options),\n\t\t\t},\n\t\t\tcause,\n\t\t})\n\t}\n\n\tif (id !== undefined && !isString(id)) {\n\t\tthrow new ContractError('Timeout: id must be a string when defined', {\n\t\t\tcode: 'literal',\n\t\t\tcontext: {\n\t\t\t\tpath: ['options', 'id'],\n\t\t\t\tlimit: 'string or undefined',\n\t\t\t\treceived: preview(id),\n\t\t\t},\n\t\t})\n\t}\n\tif (!isTimeoutDuration(ms)) {\n\t\tthrow new ContractError('Timeout: ms must be an integer in the supported range', {\n\t\t\tcode: 'range',\n\t\t\tcontext: {\n\t\t\t\tpath: ['options', 'ms'],\n\t\t\t\tlimit: `integer in [0, ${MAX_TIMEOUT_MS}]`,\n\t\t\t\treceived: preview(ms),\n\t\t\t},\n\t\t})\n\t}\n\tif (signal !== undefined && !isTimeoutSignal(signal)) {\n\t\tthrow new ContractError('Timeout: signal must be a native AbortSignal when defined', {\n\t\t\tcode: 'placement',\n\t\t\tcontext: {\n\t\t\t\tpath: ['options', 'signal'],\n\t\t\t\tlimit: 'native AbortSignal or undefined',\n\t\t\t\treceived: preview(signal),\n\t\t\t},\n\t\t})\n\t}\n\n\tif (id !== undefined && signal !== undefined) return { id, ms, signal }\n\tif (id !== undefined) return { id, ms }\n\tif (signal !== undefined) return { ms, signal }\n\treturn { ms }\n}\n","import type { TimeoutInterface, TimeoutOptions } from './types.js'\nimport { validateTimeoutOptions } from './helpers.js'\n\n/**\n * A controllable deadline whose native `AbortSignal` aborts when it expires.\n *\n * @remarks\n * `start()` arms or replaces the current deadline. Expiry sets `expired` and\n * aborts the current signal once. `clear()` cancels without aborting and resets\n * `expired`. Starting or clearing after expiry installs a fresh signal. A parent\n * abort deliberately clears the timeout without aborting its signal; once the\n * parent has aborted, later starts are inert.\n *\n * Construction validates the complete JavaScript boundary before creating an\n * `AbortController`. A malformed or unreadable options record throws a\n * `bound`-coded {@link import('@orkestrel/contract').ContractError}; an invalid\n * `id`, `ms`, or `signal` uses `literal`, `range`, or `placement`, respectively.\n *\n * @example\n * ```ts\n * import { Timeout } from '@orkestrel/timeout'\n *\n * const timeout = new Timeout({ ms: 5_000 })\n * timeout.start()\n * timeout.signal.addEventListener('abort', () => giveUp(), { once: true })\n * timeout.clear()\n * ```\n */\nexport class Timeout implements TimeoutInterface {\n\treadonly id: string\n\treadonly ms: number\n\treadonly #parent: AbortSignal | undefined\n\treadonly #listener: () => void\n\t#controller: AbortController\n\t#handle: ReturnType<typeof setTimeout> | undefined\n\t#linked = false\n\n\tconstructor(options: TimeoutOptions) {\n\t\tconst input = validateTimeoutOptions(options)\n\t\tthis.id = input.id === undefined ? crypto.randomUUID() : input.id\n\t\tthis.ms = input.ms\n\t\tthis.#parent = input.signal\n\t\tthis.#controller = new AbortController()\n\t\tthis.#listener = this.clear.bind(this)\n\t}\n\n\tget signal(): AbortSignal {\n\t\treturn this.#controller.signal\n\t}\n\n\tget expired(): boolean {\n\t\treturn this.#controller.signal.aborted\n\t}\n\n\tstart(): void {\n\t\tif (this.#parent?.aborted === true) return\n\t\tif (this.#handle !== undefined) {\n\t\t\tclearTimeout(this.#handle)\n\t\t\tthis.#handle = undefined\n\t\t}\n\t\tthis.#detach()\n\t\tif (this.#controller.signal.aborted) this.#controller = new AbortController()\n\t\tif (this.#parent !== undefined) {\n\t\t\tthis.#parent.addEventListener('abort', this.#listener, { once: true })\n\t\t\tthis.#linked = true\n\t\t}\n\t\tthis.#handle = setTimeout(() => {\n\t\t\tthis.#handle = undefined\n\t\t\tthis.#detach()\n\t\t\tthis.#controller.abort()\n\t\t}, this.ms)\n\t}\n\n\tclear(): void {\n\t\tif (this.#handle !== undefined) {\n\t\t\tclearTimeout(this.#handle)\n\t\t\tthis.#handle = undefined\n\t\t}\n\t\tthis.#detach()\n\t\tif (this.#controller.signal.aborted) this.#controller = new AbortController()\n\t}\n\n\t#detach(): void {\n\t\tif (this.#linked) {\n\t\t\tthis.#parent?.removeEventListener('abort', this.#listener)\n\t\t\tthis.#linked = false\n\t\t}\n\t}\n}\n","import type { TimeoutInterface, TimeoutOptions } from './types.js'\nimport { Timeout } from './Timeout.js'\n\n/**\n * Create a controllable deadline whose native signal aborts on expiry.\n *\n * @remarks\n * `options.ms` must be an integer from `0` through `2_147_483_647`, inclusive.\n * A parent `options.signal` clears an armed timeout without aborting the\n * timeout's own signal. Omitted `options.id` values generate a random UUID.\n * Malformed or unreadable options use contract code `bound`; invalid `id`,\n * `ms`, and `signal` values use `literal`, `range`, and `placement`.\n *\n * @param options - Validated deadline, optional trace label, and optional native parent signal\n * @returns A reusable timeout handle\n * @throws {@link import('@orkestrel/contract').ContractError} When the\n * JavaScript input does not satisfy `TimeoutOptions`\n *\n * @example\n * ```ts\n * import { createTimeout } from '@orkestrel/timeout'\n *\n * const timeout = createTimeout({ ms: 5_000 })\n * timeout.start()\n * try {\n * \tawait fetch('/work', { signal: timeout.signal })\n * } finally {\n * \ttimeout.clear()\n * }\n * ```\n */\nexport function createTimeout(options: TimeoutOptions): TimeoutInterface {\n\treturn new Timeout(options)\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orkestrel/timeout",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "A typed, controllable setTimeout wrapper — an AbortSignal deadline with start/clear lifecycle and parent-signal linking. Part of the @orkestrel line.",
5
5
  "keywords": [
6
6
  "abort-signal",
@@ -64,18 +64,18 @@
64
64
  "prepublishOnly": "npm run format:check && npm run lint:check && npm run check && npm run build && npm test"
65
65
  },
66
66
  "dependencies": {
67
- "@orkestrel/contract": "^0.0.8"
67
+ "@orkestrel/contract": "^0.0.9"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@microsoft/api-extractor": "^7.58.12",
71
- "@orkestrel/guide": "^0.0.5",
72
- "@orkestrel/scaffold": "^0.0.6",
71
+ "@orkestrel/guide": "^0.0.8",
72
+ "@orkestrel/scaffold": "^0.0.18",
73
73
  "@types/node": "^26.1.2",
74
74
  "@vitest/browser-playwright": "^4.1.10",
75
- "oxfmt": "^0.61.0",
76
- "oxlint": "^1.76.0",
75
+ "oxfmt": "^0.62.0",
76
+ "oxlint": "^1.77.0",
77
77
  "typescript": "^6.0.3",
78
- "vite": "^8.1.5",
78
+ "vite": "^8.2.0",
79
79
  "vite-plugin-dts": "^5.0.3",
80
80
  "vitest": "^4.1.10"
81
81
  },