@randajan/pulse 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,174 +1,242 @@
1
1
  # @randajan/pulse
2
2
 
3
-
4
- [![NPM](https://img.shields.io/npm/v/@randajan/pulse.svg)](https://www.npmjs.com/package/@randajan/pulse) 
3
+ [![NPM](https://img.shields.io/npm/v/@randajan/pulse.svg)](https://www.npmjs.com/package/@randajan/pulse)
5
4
  [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
6
5
 
6
+ A promise-friendly scheduler with aligned or relative intervals, isolated execution sessions, cancellation signals, and structured error reporting. Suitable for heartbeats, telemetry, maintenance tasks, and other recurring asynchronous work.
7
7
 
8
- ---
9
-
10
- ## Overview
8
+ Supports ESM and CommonJS, runs in Node.js and browsers, and includes TypeScript declarations.
11
9
 
12
- **@randajan/pulse** is a tiny, zero‑dependency scheduler that fires your callback on aligned or relative intervals. It focuses on precision, clarity and graceful error handling, so you can drive heart‑beat logic, telemetry or background maintenance tasks with just a few lines of code.
10
+ > **Upgrading from v2?** Version 3 has breaking API changes. See [Migrating from v2 to v3](#migrating-from-v2-to-v3) before updating.
13
11
 
14
- The package ships dual builds—**ESM** *and* **CommonJS**—and works the same in Node.js and the browser.
12
+ ## Installation
15
13
 
16
- ### ESM **&** CommonJS ready
14
+ ```bash
15
+ npm install @randajan/pulse
16
+ ```
17
17
 
18
18
  ```js
19
19
  // ESM
20
- import createPulse, { Pulse } from "@randajan/pulse";
20
+ import createPulse, { Pulse, PulseSession, PulseCycle } from "@randajan/pulse";
21
21
 
22
22
  // CommonJS
23
- const createPulse = require("@randajan/pulse");
23
+ const { default: createPulse, Pulse } = require("@randajan/pulse");
24
24
  ```
25
25
 
26
- ---
27
-
28
- ## Quick start
26
+ ## Quick start
29
27
 
30
28
  ```js
31
29
  import createPulse from "@randajan/pulse";
32
30
 
33
31
  const pulse = createPulse({
34
- interval: 1000, // 1 second
35
- align: true, // align to wall-clock time
36
- onPulse: (pulse, meta) => {
37
- console.log("tick", meta.runtime, "ms");
38
- },
39
- onError: (pulse, meta) => console.error("Pulse error:", meta.error),
40
- autoStart: true // start immediately
32
+ interval: 1000,
33
+ align: true,
34
+ autoStart: true,
35
+ onPulse: async cycle => {
36
+ cycle.warn("Example warning");
37
+ console.log(`Pulse #${cycle.id}`, cycle.session.state);
38
+ return "done";
39
+ },
40
+ onError: (pulse, type, error) => console.error(`[pulse] ${type}:`, error.cause ?? error)
41
41
  });
42
42
 
43
- // Later…
43
+ // Later
44
44
  setTimeout(() => pulse.stop(), 10_000);
45
45
  ```
46
46
 
47
- Every time **onPulse** runs it receives the pulse instance and a fresh metadata object:
47
+ **`onPulse` receives exactly one argument: a `PulseCycle`.** Access its parent through `cycle.session` and `cycle.pulse`. No separate metadata object is created.
48
48
 
49
- ```ts
50
- interface PulseMeta {
51
- readonly id: number;
52
- readonly started: Date;
53
- readonly runtime: number; // ms since started
54
- readonly lateBy: number; // ms over interval runtime
55
- readonly warnings: string[];
56
- warn(w: string | Error): void; // push warning
49
+ ## Architecture
50
+
51
+ | Class | Responsibility | Lifetime |
52
+ | --- | --- | --- |
53
+ | `Pulse` | Public API, configuration, global `nextId`, and the last completed cycle | Until the Pulse instance is discarded |
54
+ | `PulseSession` | One active start/stop period: timers, planning, `AbortController`, current and last cycle | Created by each `pulse.start()` |
55
+ | `PulseCycle` | One execution: result, error, timestamps, warnings, and callback context | Created for each scheduled execution |
56
+
57
+ ```js
58
+ onPulse: async cycle => {
59
+ const pulse = cycle.pulse;
60
+ const session = cycle.session;
61
+ const signal = cycle.signal; // session's AbortSignal
62
+
63
+ // Pass signal to cancellable work when available.
64
+ const response = await fetch("/health", { signal });
65
+ return response.status;
57
66
  }
58
67
  ```
59
68
 
60
- ---
69
+ Stopping or restarting aborts the **session's signal**. This does **not** forcibly terminate an already running Promise: callback code must cooperate with cancellation. Each new session has its own signal, so late completion of an old cycle cannot reschedule the new session.
70
+
71
+ A session runs at most one cycle at a time. The next cycle is planned after the previous callback settles.
61
72
 
62
- ## `createPulse(options)` — options reference
73
+ ## Options
63
74
 
64
- | Option | Type | Required | Default | Description |
65
- | ----------- | ---------- | -------- | ------------------ | ------------------------------------------------------------------ |
66
- | `interval` | `number` | ✔︎ | — | Period in ms. Min **10 ms**, max **2 147 483 647 ms** (\~24 days). |
67
- | `align` | `boolean` | ✔︎ | — | If `true`, pulse is aligned to wall-clock interval edges. If `false`, pulse runs relatively from start. |
68
- | `offset` | `number` |   | `0` | Timing offset. Range and meaning depend on `align`; see below. |
69
- | `maxRuns` | `number` |   | `0` | Maximum number of pulses before automatic stop. `0` means unlimited. |
70
- | `getNow` | `function` |   | `() => Date.now()` | Custom clock—handy for deterministic tests or time travel. |
71
- | `autoStart` | `boolean` |   | `false` | If `true`, the scheduler starts right after construction. |
72
- | `runOnStart` | `boolean` |   | `false` | If `true`, the first pulse is scheduled immediately on start. |
73
- | `noMeta` | `boolean` |   | `false` | If `true`, callbacks receive the numeric pulse id instead of `PulseMeta`. |
74
- | `onPulse` | `function` | ✔︎ | — | Async/sync callback executed on each pulse. Receives `(pulse, meta)`. |
75
- | `onError` | `function` |   | `() => {}` | Called when `onPulse` throws or rejects. Receives `(pulse, meta)`, or `(pulse, error, id)` with `noMeta: true`. |
76
- | `onStart` | `function` |   | `() => {}` | Called right before pulse starts. |
77
- | `onStop` | `function` |   | `() => {}` | Called right after pulse stops. |
78
- | `afterPulse` | `function` | | `() => {}` | Async/sync callback executed after each pulse even if `onPulse` raises an error. Receives `(pulse, meta)`. |
75
+ | Option | Type | Required | Default | Description |
76
+ | --- | --- | --- | --- | --- |
77
+ | `interval` | `number` | Yes | — | Period in milliseconds, from `10` to `2_147_483_647`. |
78
+ | `align` | `boolean` | Yes | — | Align to clock boundaries when `true`; schedule relative delays when `false`. |
79
+ | `offset` | `number` | No | `0`* | Shift the schedule. See [Scheduling](#scheduling). |
80
+ | `runOnStart` | `boolean` | No | `false` | Schedule the first cycle with a zero-millisecond delay. |
81
+ | `getNow` | `() => number` | No | `Date.now` | Clock returning finite, nonnegative milliseconds. Validated on construction and on every call. |
82
+ | `autoStart` | `boolean` | No | `false` | Automatically call `start()` in the constructor. |
83
+ | `onPulse` | `(cycle) => value \| Promise` | Yes | — | Execute the task; receives a `PulseCycle`. |
84
+ | `onStart` | `(session) => void` | No | No-op | Called before the first timer is scheduled. Throwing prevents startup. |
85
+ | `onStop` | `(session, reason) => void` | No | No-op | Called after the session has stopped, optionally with the stop reason. |
86
+ | `onError` | `(pulse, type, error) => void` | No | Console logging | Error notification; does **not** block the scheduler. |
79
87
 
88
+ *With `align: false` and `runOnStart: true`, `offset` must be omitted and the normalized offset is `-interval` internally.*
80
89
 
81
- ### `align` and `offset`
90
+ ### Scheduling
82
91
 
83
- With `align: true`, pulses are scheduled on wall-clock interval edges shifted by `offset`. In this mode `offset` must be between `0` and `interval`.
92
+ **Aligned mode:** `align: true` schedules the next cycle on the next clock boundary, shifted by `offset` (from `0` to `interval`). For example, run at the 30-second mark of each minute:
84
93
 
85
94
  ```js
86
95
  createPulse({
87
- align: true,
88
- interval: 60_000,
89
- offset: 30_000,
90
- onPulse: () => console.log("fires around every minute at :30")
96
+ align: true, interval: 60_000, offset: 30_000,
97
+ onPulse: cycle => console.log("minute :30", cycle.id),
98
+ autoStart: true
91
99
  });
92
100
  ```
93
101
 
94
- With `align: false`, the first pulse is delayed by `interval + offset`; every following pulse uses `interval`. In this mode `offset` can be negative down to `-interval`, or positive as long as `interval + offset` does not exceed the maximum JavaScript timer delay.
102
+ **Relative mode:** `align: false` waits `interval + offset` before the first cycle and `interval` after each completed cycle. The initial offset may be negative down to `-interval` or positive as long as the initial delay fits the JavaScript timer limit.
95
103
 
96
104
  ```js
97
105
  createPulse({
98
- align: false,
99
- interval: 5_000,
100
- offset: -4_000,
101
- onPulse: () => console.log("first fire after 1s, then every 5s")
102
- });
106
+ align: false, interval: 5000, offset: -4000,
107
+ onPulse: cycle => console.log("pulse", cycle.id),
108
+ autoStart: true
109
+ }); // First execution after ~1s; subsequent delays are 5s after completion.
103
110
  ```
104
111
 
105
- In Node.js, Pulse calls `timeout.unref?.()` for its internal timers. A running pulse will not keep an otherwise idle Node process alive.
112
+ With `runOnStart: true`, the first cycle is scheduled immediately (via a zero-delay timer), regardless of alignment. Subsequent executions follow the selected schedule.
113
+
114
+ Internal timers use `timeout.unref?.()` when available, so an otherwise idle Node.js process is not kept alive by Pulse alone. Timer precision is still subject to runtime scheduling delays.
115
+
116
+ ## Public API
117
+
118
+ ### `Pulse`
119
+
120
+ | Member | Description |
121
+ | --- | --- |
122
+ | `createPulse(options)` / `new Pulse(options)` | Create a scheduler. |
123
+ | `pulse.start()` | Start a **new** session; returns `false` if already running. |
124
+ | `pulse.stop(reason?)` | Stop the active session; returns `false` if already stopped. |
125
+ | `pulse.restart()` | Stop the current session and create a new one. |
126
+ | `pulse.session` | Active `PulseSession`, or `null` after stop. |
127
+ | `pulse.state` | Whether the active session is running. |
128
+ | `pulse.current` | Active `PulseCycle`, if any. |
129
+ | `pulse.last` | Last completed cycle, preserved across stops/restarts until replaced. |
130
+ | `pulse.nextId` | Next globally increasing cycle ID; **not** reset on restart. |
131
+ | `pulse.interval`, `pulse.offset`, `pulse.align` | Read-only configuration. |
132
+ | `pulse.countdown` | Milliseconds remaining until the scheduled timer, or `undefined`. |
133
+ | `pulse.potential` | Remaining countdown, or computed delay until a potential initial execution. |
134
+ | `pulse.nextAt` | Expected next execution timestamp in milliseconds, or `undefined`. |
135
+ | `pulse.calculatePotential(isInit?)` | Compute the next delay (initial behavior by default). |
136
+ | `pulse.toJSON()` | Snapshot of public scheduler state. |
137
+
138
+ ### `PulseSession`
139
+
140
+ Available from `pulse.session` while running or via `cycle.session`, even after a stop. Exposes `pulse`, `signal`, `state`, `current`, `last`, `nextId`, `interval`, `offset`, `align`, `countdown`, `potential`, `nextAt`, `stop(reason?)`, `calculateCountdown()` and `toJSON()`.
106
141
 
107
- ### `runOnStart`
142
+ Calling `session.stop()` on the active session also detaches it from its parent `Pulse`.
108
143
 
109
- With `runOnStart: true`, the first pulse is scheduled with a `0` ms delay when `start()` is called. The callback still runs asynchronously through the scheduler, not directly inside `start()`.
144
+ ### `PulseCycle`
145
+
146
+ | Member | Description |
147
+ | --- | --- |
148
+ | `cycle.id` | Globally sequential cycle ID. |
149
+ | `cycle.pulse`, `cycle.session`, `cycle.signal` | Parent scheduler, its session and cancellation signal. |
150
+ | `cycle.started`, `cycle.ended` | Millisecond timestamps (`ended` is set after completion). |
151
+ | `cycle.done`, `cycle.ok` | Whether execution finished and whether `onPulse` succeeded. |
152
+ | `cycle.runtime` | Execution time in milliseconds, including elapsed time while running. |
153
+ | `cycle.lateBy` | `max(0, runtime - interval)` — runtime overrun, not timer scheduling latency. |
154
+ | `cycle.result`, `cycle.error` | Resolved return value or thrown/rejected value of `onPulse`. |
155
+ | `cycle.warnings`, `cycle.warn(warning)` | Snapshot of collected warnings; append a warning (`string` or `Error`). |
156
+ | `cycle.toJSON()` | Snapshot of cycle metadata. |
110
157
 
111
158
  ```js
112
- createPulse({
113
- align: false,
114
- interval: 60_000,
115
- runOnStart: true,
116
- onPulse: () => console.log("runs now, then every minute")
117
- });
159
+ onPulse: async cycle => {
160
+ cycle.warn("Processing started");
161
+ const value = await doWork(cycle.signal);
162
+ return value; // available as cycle.result after completion
163
+ }
118
164
  ```
119
165
 
120
- `runOnStart` only affects the initial plan after `start()`. After the first pulse completes, the next pulse is scheduled normally: aligned to the wall-clock grid when `align: true`, or after `interval` when `align: false`.
166
+ ## Error handling
121
167
 
122
- ---
168
+ `onPulse` failures are recorded on the `PulseCycle` and reported as `PulseRuntimeError`. They do **not** stop the session; another cycle is scheduled after completion. Scheduling failures and unexpected execution failures stop the session and are reported through `onError`.
123
169
 
124
- ## API
170
+ ```js
171
+ import { PulseRuntimeError, PulseScheduleError } from "@randajan/pulse";
125
172
 
126
- | Member | Returns | Description |
127
- | ---------------------- | --------- | ---------------------------------------------------- |
128
- | `createPulse(options)` | `Pulse` | Convenience wrapper around `new Pulse(options)`. |
129
- | `new Pulse(options)` | `Pulse` | Class constructor when you prefer `new`. |
130
- | `pulse.start()` | `boolean` | Starts the loop; returns `false` if already running. |
131
- | `pulse.stop()` | `boolean` | Stops the loop; returns `false` if already stopped. |
132
- | `pulse.restart()` | `boolean` | Call pulse.stop() and then pulse.start() |
133
- | `pulse.reset()` | `boolean` | Resets nextId counter |
134
- | `pulse.state` | `boolean` | `true` = running, `false` = stopped (read-only) |
135
- | `pulse.align` | `boolean` | Alignment mode (read-only). |
136
- | `pulse.interval` | `number` | Interval in ms (read‑only). |
137
- | `pulse.offset` | `number` | Offset in ms (read‑only). |
138
- | `pulse.countdown` | `number\|undefined` | Real ms remaining to the currently scheduled pulse; `undefined` while stopped. |
139
- | `pulse.potential` | `number` | Real countdown while running, otherwise ms that would be used by the next `start()`; returns `0` when `runOnStart` applies. |
140
- | `pulse.nextAt` | `number\|undefined` | Timestamp of the next scheduled pulse while running. |
141
- | `pulse.last` | `any\|PulseMeta` | Last callback result with `noMeta: true`; otherwise last metadata. |
173
+ createPulse({
174
+ interval: 1000, align: false,
175
+ onPulse: cycle => doWork(cycle.signal),
176
+ onError: (pulse, type, error) => {
177
+ console.error(type, error.cause ?? error);
178
+ if (error instanceof PulseScheduleError) console.warn("Scheduling stopped");
179
+ if (error instanceof PulseRuntimeError) console.warn("Next cycle will still run");
180
+ }
181
+ });
182
+ ```
142
183
 
143
- ---
184
+ | Class | `type` | When reported | Effect |
185
+ | --- | --- | --- | --- |
186
+ | `PulseStartError` | `start` | `onStart` throws | Start fails and throws; session is cancelled. |
187
+ | `PulseStopError` | `stop` | `onStop` throws | Session remains stopped; error is reported. |
188
+ | `PulseScheduleError` | `schedule` | Planning the next timer fails | Session stops; no automatic retry. |
189
+ | `PulseExecuteError` | `execute` | Unexpected error while preparing/executing a cycle | Session stops. |
190
+ | `PulseRuntimeError` | `runtime` | `onPulse` throws or rejects | Cycle records failure; session continues. |
144
191
 
145
- ## Why not `setInterval`?
192
+ All classes derive from `PulseError`, expose `pulseErrType` and preserve the original failure in `error.cause`. When `onError` is absent, errors are logged to the console. If it returns a Promise, Pulse **does not await it** before scheduling more work; failures inside `onError` are logged instead of recursively reported.
146
193
 
147
- `setInterval` drifts: extra work inside the callback pushes the next tick further, eventually desynchronising your schedule. With `align: true`, **Pulse** recalculates the next edge *after* each run and keeps the loop tightly aligned to the wall-clock grid. With `align: false`, it runs as a promise-friendly relative scheduler.
194
+ `onStart` can prevent startup. `onStop` runs only *after* the session has been stopped, so an `onStop` error cannot reactivate it. `stop(reason)` forwards a supplied reason to `onStop` and reports any non-null reason via `onError` (an arbitrary reason is classified as `unknown`).
148
195
 
149
- ---
196
+ ## Migrating from v2 to v3
150
197
 
151
- ## Recipes
198
+ **v3 is a breaking release.** Update all callbacks and remove obsolete options before upgrading.
152
199
 
153
- ### Long delays (> 24 days)
200
+ | v2 | v3 | Migration action |
201
+ | --- | --- | --- |
202
+ | `onPulse(pulse, meta)` | `onPulse(cycle)` | Use `cycle.pulse`, `cycle.session` or `cycle.signal` as needed. |
203
+ | `onError(pulse, meta)` / `onError(pulse, error, id)` | `onError(pulse, type, error)` | Read original failure from `error.cause`; error reporting is non-blocking. |
204
+ | `onStart(pulse)` | `onStart(session)` | Access parent via `session.pulse`. |
205
+ | `onStop(pulse)` | `onStop(session, reason)` | Use session context and optional stop reason. |
206
+ | `PulseMeta` / numeric ID with `noMeta` | `PulseCycle` | Metadata always exists as a cycle instance. |
207
+ | `noMeta` | Removed | Delete the option; specifying it throws. |
208
+ | `maxRuns` | Removed | Implement an explicit counter in your callback if needed. |
209
+ | `afterPulse` | Removed | Place cleanup in `onPulse` using `try/finally`. |
210
+ | `pulse.reset()` | Removed | Cycle IDs are global to each `Pulse` instance and are not reset. |
211
+ | `start(reset)`, `stop(reset)`, `restart(reset)` | `start()`, `stop(reason?)`, `restart()` | Remove reset flags; `stop` now accepts an optional reason. |
212
+ | Metadata timestamps / `last` result | Millisecond timestamps / last `PulseCycle` | `started` is a number, and `pulse.last?.result` holds the callback result. |
154
213
 
155
- JavaScript timers accept at most **2 147 483 647 ms**. To wait longer, chain pulses:
214
+ ### Callback migration example
156
215
 
157
216
  ```js
217
+ // v2
158
218
  createPulse({
159
- align: false,
160
- interval: 2_147_483_647,
161
- onPulse: async (pulse, meta) => {
162
- if (stillWaiting()) return; // keep sleeping…
163
- // …real logic here
164
- },
165
- autoStart: true
219
+ interval: 1000, align: false,
220
+ onPulse: (pulse, meta) => console.log(meta.id, pulse.state),
221
+ onError: (pulse, meta) => console.error(meta.error),
222
+ onStart: pulse => console.log(pulse.state)
223
+ });
224
+
225
+ // v3
226
+ createPulse({
227
+ interval: 1000, align: false,
228
+ onPulse: cycle => console.log(cycle.id, cycle.pulse.state),
229
+ onError: (pulse, type, error) => console.error(type, error.cause),
230
+ onStart: session => console.log(session.state)
166
231
  });
167
232
  ```
168
233
 
234
+ For code previously using `maxRuns`, keep an application-level counter and call `cycle.session.stop()` when you reach the desired count. For cleanup previously handled by `afterPulse`, place it in `finally` inside `onPulse`.
235
+
236
+ ## Why not `setInterval`?
169
237
 
170
- ---
238
+ A standard `setInterval` can invoke a callback again while an earlier asynchronous invocation is still running. Pulse waits for `onPulse` to settle before planning another execution. In aligned mode, it recalculates the next clock boundary rather than accumulating delays from previous executions. In relative mode, each subsequent delay starts after the previous cycle completes.
171
239
 
172
240
  ## License
173
241
 
174
- MIT © [randajan](https://github.com/randajan)
242
+ MIT © [randajan](https://github.com/randajan)