@randajan/pulse 1.1.4 → 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,125 +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 at *wall‑clock‑aligned* intervals. It focuses on precision (no drift), 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
- onPulse: meta => {
36
- console.log("tick", meta.runtime, "ms");
37
- },
38
- onError: err => console.error("Pulse error:", err),
39
- 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)
40
41
  });
41
42
 
42
- // Later…
43
+ // Later
43
44
  setTimeout(() => pulse.stop(), 10_000);
44
45
  ```
45
46
 
46
- Every time **onPulse** runs it receives 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.
47
48
 
48
- ```ts
49
- interface PulseMeta {
50
- readonly started: Date;
51
- readonly runtime: number; // ms since started
52
- readonly warnings: string[];
53
- 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;
54
66
  }
55
67
  ```
56
68
 
57
- ---
58
-
59
- ## `createPulse(options)` — options reference
60
-
61
- | Option | Type | Required | Default | Description |
62
- | ----------- | ---------- | -------- | ------------------ | ------------------------------------------------------------------ |
63
- | `interval` | `number` | ✔︎ | — | Period in ms. Min **10 ms**, max **2 147 483 647 ms** (\~24 days). |
64
- | `offset` | `number` |   | `0` | Fixed shift applied to every pulse (0 ≤ offset < interval). |
65
- | `getNow` | `function` |   | `() => Date.now()` | Custom clock—handy for deterministic tests or time travel. |
66
- | `autoStart` | `boolean` |   | `false` | If `true`, the scheduler starts right after construction. |
67
- | `noMeta` | `boolean` |   | `false` | If `false`, the scheduler creates metadata `PuleMeta` at runtime |
68
- | `onPulse` | `function` | ✔︎ | — | Async/sync callback executed on each pulse. Receives `PulseMeta`. |
69
- | `onError` | `function` |   | `() => {}` | Called when `onPulse` throws or rejects. |
70
- | `onStart` | `function` |   | `() => {}` | Called right before pulse starts. |
71
- | `onStop` | `function` |   | `() => {}` | Called right after pulse stops. |
72
- | `afterPulse` | `function` | | `() => {}` | Async/sync callback executed after each pulse even if the onPulse raises an error. Receives `PulseMeta`. |
73
-
74
-
75
- ---
76
-
77
- ## API
78
-
79
- | Member | Returns | Description |
80
- | ---------------------- | --------- | ---------------------------------------------------- |
81
- | `createPulse(options)` | `Pulse` | Convenience wrapper around `new Pulse(options)`. |
82
- | `new Pulse(options)` | `Pulse` | Class constructor when you prefer `new`. |
83
- | `pulse.start()` | `boolean` | Starts the loop; returns `false` if already running. |
84
- | `pulse.stop()` | `boolean` | Stops the loop; returns `false` if already stopped. |
85
- | `pulse.restart()` | `boolean` | Call pulse.stop() and then pulse.start() |
86
- | `pulse.reset()` | `boolean` | Resets nextId counter |
87
- | `pulse.state` | `boolean` | `true` = running, `false` = stopped (read-only) |
88
- | `pulse.nextId` | `number` | next pulse id (read-only). |
89
- | `pulse.interval` | `number` | Interval in ms (read‑only). |
90
- | `pulse.offset` | `number` | Offset in ms (read‑only). |
91
- | `pulse.countdown` | `number` | number of ms that remains to the next pulse |
92
- | `pulse.last` | `number` | if noMeta=true there will be last result, else there will be last metadata |
93
-
94
- ---
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.
95
70
 
96
- ## Why not `setInterval`?
71
+ A session runs at most one cycle at a time. The next cycle is planned after the previous callback settles.
97
72
 
98
- `setInterval` drifts: extra work inside the callback pushes the next tick further, eventually desynchronising your schedule. **Pulse** recalculates the next edge *after* each run and keeps the loop tightly aligned to the original wall‑clock grid.
73
+ ## Options
99
74
 
100
- ---
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. |
101
87
 
102
- ## Recipes
88
+ *With `align: false` and `runOnStart: true`, `offset` must be omitted and the normalized offset is `-interval` internally.*
103
89
 
104
- ### Long delays (> 24 days)
90
+ ### Scheduling
105
91
 
106
- JavaScript timers accept at most **2 147 483 647 ms**. To wait longer, chain pulses:
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:
107
93
 
108
94
  ```js
109
95
  createPulse({
110
- interval: 2_147_483_647,
111
- onPulse: async meta => {
112
- if (stillWaiting()) return; // keep sleeping…
113
- // …real logic here
114
- },
115
- autoStart: true
96
+ align: true, interval: 60_000, offset: 30_000,
97
+ onPulse: cycle => console.log("minute :30", cycle.id),
98
+ autoStart: true
116
99
  });
117
100
  ```
118
101
 
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.
119
103
 
120
- ---
104
+ ```js
105
+ createPulse({
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.
110
+ ```
121
111
 
122
- ## License
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()`.
141
+
142
+ Calling `session.stop()` on the active session also detaches it from its parent `Pulse`.
143
+
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. |
157
+
158
+ ```js
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
+ }
164
+ ```
165
+
166
+ ## Error handling
167
+
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
- MIT © [randajan](https://github.com/randajan)
170
+ ```js
171
+ import { PulseRuntimeError, PulseScheduleError } from "@randajan/pulse";
172
+
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
+ ```
183
+
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. |
191
+
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.
193
+
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`).
195
+
196
+ ## Migrating from v2 to v3
197
+
198
+ **v3 is a breaking release.** Update all callbacks and remove obsolete options before upgrading.
199
+
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. |
213
+
214
+ ### Callback migration example
215
+
216
+ ```js
217
+ // v2
218
+ createPulse({
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)
231
+ });
232
+ ```
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`?
237
+
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.
239
+
240
+ ## License
125
241
 
242
+ MIT © [randajan](https://github.com/randajan)