@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 +197 -80
- package/dist/cjs/index.cjs +445 -147
- package/dist/cjs/index.cjs.map +4 -4
- package/dist/esm/index.mjs +431 -147
- package/dist/esm/index.mjs.map +4 -4
- package/dist/types/index.d.ts +174 -0
- package/package.json +11 -8
package/README.md
CHANGED
|
@@ -1,125 +1,242 @@
|
|
|
1
1
|
# @randajan/pulse
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](https://www.npmjs.com/package/@randajan/pulse)
|
|
3
|
+
[](https://www.npmjs.com/package/@randajan/pulse)
|
|
5
4
|
[](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
|
-
|
|
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
|
-
|
|
12
|
+
## Installation
|
|
15
13
|
|
|
16
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
71
|
+
A session runs at most one cycle at a time. The next cycle is planned after the previous callback settles.
|
|
97
72
|
|
|
98
|
-
|
|
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
|
-
|
|
88
|
+
*With `align: false` and `runOnStart: true`, `offset` must be omitted and the normalized offset is `-interval` internally.*
|
|
103
89
|
|
|
104
|
-
###
|
|
90
|
+
### Scheduling
|
|
105
91
|
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|