cronwatch 1.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 +228 -0
- package/package.json +33 -0
- package/src/config.js +55 -0
- package/src/index.js +84 -0
- package/src/logger.js +98 -0
- package/src/plugin.js +50 -0
- package/src/retry.js +43 -0
- package/src/store.js +71 -0
- package/src/tracker.js +107 -0
- package/types/index.d.ts +142 -0
package/README.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# CronWatch
|
|
2
|
+
|
|
3
|
+
Lightweight cron job tracker, debugger, and monitor for Node.js applications.
|
|
4
|
+
|
|
5
|
+
Zero dependencies. Full TypeScript support. Plugin-ready.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install cronwatch
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
const { createCronWatch } = require('cronwatch');
|
|
17
|
+
|
|
18
|
+
const cron = createCronWatch();
|
|
19
|
+
|
|
20
|
+
await cron.trackJob('send-emails', async () => {
|
|
21
|
+
// your job logic here
|
|
22
|
+
await sendPendingEmails();
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Every call to `trackJob` automatically captures start/end time, duration, success/failure status, error stacks, and retry counts.
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
const cron = createCronWatch({
|
|
32
|
+
retries: 3, // max retry attempts (default: 0)
|
|
33
|
+
retryDelay: 1000, // base delay between retries in ms (default: 1000)
|
|
34
|
+
retryBackoff: 'exponential', // 'fixed' | 'linear' | 'exponential'
|
|
35
|
+
timeout: 30000, // job timeout in ms, 0 = disabled (default: 0)
|
|
36
|
+
logLevel: LOG_LEVELS.INFO, // DEBUG | INFO | WARN | ERROR | SILENT
|
|
37
|
+
storeMaxEntries: 1000, // max entries kept in memory (default: 1000)
|
|
38
|
+
timestamps: true, // ISO timestamps in log output (default: true)
|
|
39
|
+
colorize: true, // colorized console output (default: true)
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Per-job overrides:
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
await cron.trackJob('heavy-job', jobFn, {
|
|
47
|
+
retries: 5,
|
|
48
|
+
retryDelay: 2000,
|
|
49
|
+
retryBackoff: 'linear',
|
|
50
|
+
timeout: 60000,
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Structured Logs
|
|
55
|
+
|
|
56
|
+
Every tracked job produces a structured entry:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"jobName": "send-emails",
|
|
61
|
+
"status": "success",
|
|
62
|
+
"startTime": "2026-03-20T10:00:00.000Z",
|
|
63
|
+
"endTime": "2026-03-20T10:00:00.234Z",
|
|
64
|
+
"duration": 234,
|
|
65
|
+
"error": null,
|
|
66
|
+
"retries": 0
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
On failure:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"jobName": "sync-inventory",
|
|
75
|
+
"status": "failure",
|
|
76
|
+
"startTime": "2026-03-20T10:00:00.000Z",
|
|
77
|
+
"endTime": "2026-03-20T10:00:03.512Z",
|
|
78
|
+
"duration": 3512,
|
|
79
|
+
"error": {
|
|
80
|
+
"message": "Connection refused",
|
|
81
|
+
"stack": "Error: Connection refused\n at ...",
|
|
82
|
+
"code": null
|
|
83
|
+
},
|
|
84
|
+
"retries": 3
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Querying Logs
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
const emailLogs = await cron.getJobLogs('send-emails');
|
|
92
|
+
const allLogs = await cron.getAllLogs();
|
|
93
|
+
await cron.clearLogs();
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Plugin System
|
|
97
|
+
|
|
98
|
+
Extend CronWatch by hooking into job lifecycle events.
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
cron.use({
|
|
102
|
+
name: 'slack-alerter',
|
|
103
|
+
onFailure({ jobName, error }) {
|
|
104
|
+
slack.send(`Job ${jobName} failed: ${error.message}`);
|
|
105
|
+
},
|
|
106
|
+
onTimeout({ jobName }) {
|
|
107
|
+
slack.send(`Job ${jobName} timed out!`);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Available Hooks
|
|
113
|
+
|
|
114
|
+
| Hook | Trigger |
|
|
115
|
+
|------|---------|
|
|
116
|
+
| `onStart` | Before job executes |
|
|
117
|
+
| `onSuccess` | Job completed successfully |
|
|
118
|
+
| `onFailure` | Job failed after all retries |
|
|
119
|
+
| `onRetry` | Before each retry attempt |
|
|
120
|
+
| `onTimeout` | Job exceeded timeout |
|
|
121
|
+
|
|
122
|
+
## Custom Store Adapter
|
|
123
|
+
|
|
124
|
+
By default, logs are kept in memory. You can plug in any backend:
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
const { createCronWatch, StoreAdapter } = require('cronwatch');
|
|
128
|
+
|
|
129
|
+
class MongoAdapter extends StoreAdapter {
|
|
130
|
+
async save(entry) { /* insert into MongoDB */ }
|
|
131
|
+
async getByJob(jobName) { /* query by jobName */ }
|
|
132
|
+
async getAll() { /* return all entries */ }
|
|
133
|
+
async clear() { /* drop collection */ }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const cron = createCronWatch({
|
|
137
|
+
storeAdapter: new MongoAdapter(),
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
See `examples/custom-store-adapter.js` for a working file-based adapter.
|
|
142
|
+
|
|
143
|
+
## Use with node-cron
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
const nodeCron = require('node-cron');
|
|
147
|
+
const { createCronWatch } = require('cronwatch');
|
|
148
|
+
|
|
149
|
+
const cron = createCronWatch({ retries: 2, timeout: 10000 });
|
|
150
|
+
|
|
151
|
+
nodeCron.schedule('*/5 * * * *', () => {
|
|
152
|
+
cron.trackJob('cleanup-temp-files', async () => {
|
|
153
|
+
await cleanupTempFiles();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## TypeScript
|
|
159
|
+
|
|
160
|
+
Full type definitions are included. Import and use directly:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
import { createCronWatch, CronWatchPlugin, JobEntry } from 'cronwatch';
|
|
164
|
+
|
|
165
|
+
const cron = createCronWatch({ retries: 2 });
|
|
166
|
+
|
|
167
|
+
const plugin: CronWatchPlugin = {
|
|
168
|
+
name: 'my-plugin',
|
|
169
|
+
onSuccess({ jobName }) {
|
|
170
|
+
console.log(`${jobName} done`);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
cron.use(plugin);
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## API Reference
|
|
178
|
+
|
|
179
|
+
### `createCronWatch(options?)`
|
|
180
|
+
|
|
181
|
+
Creates a new CronWatch instance.
|
|
182
|
+
|
|
183
|
+
### `instance.trackJob(jobName, jobFn, options?)`
|
|
184
|
+
|
|
185
|
+
Executes and tracks a job. Returns a `Promise<JobEntry>`.
|
|
186
|
+
|
|
187
|
+
### `instance.use(plugin)`
|
|
188
|
+
|
|
189
|
+
Registers a plugin. Returns `this` for chaining.
|
|
190
|
+
|
|
191
|
+
### `instance.getJobLogs(jobName)`
|
|
192
|
+
|
|
193
|
+
Returns all log entries for a specific job.
|
|
194
|
+
|
|
195
|
+
### `instance.getAllLogs()`
|
|
196
|
+
|
|
197
|
+
Returns all log entries.
|
|
198
|
+
|
|
199
|
+
### `instance.clearLogs()`
|
|
200
|
+
|
|
201
|
+
Clears the log store.
|
|
202
|
+
|
|
203
|
+
## Architecture
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
src/
|
|
207
|
+
config.js - Centralized config with defaults and merging
|
|
208
|
+
logger.js - Multi-level logger with pluggable outputs
|
|
209
|
+
store.js - In-memory store with adapter interface
|
|
210
|
+
retry.js - Retry engine with backoff strategies
|
|
211
|
+
plugin.js - Plugin lifecycle manager
|
|
212
|
+
tracker.js - Core trackJob orchestrator
|
|
213
|
+
index.js - Public API surface
|
|
214
|
+
types/
|
|
215
|
+
index.d.ts - Full TypeScript definitions
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Roadmap
|
|
219
|
+
|
|
220
|
+
- [ ] Database adapters (MongoDB, PostgreSQL, Redis)
|
|
221
|
+
- [ ] Dashboard API (Express/Fastify)
|
|
222
|
+
- [ ] Alert system (email, webhooks, Slack)
|
|
223
|
+
- [ ] Job scheduling (built-in cron parser)
|
|
224
|
+
- [ ] Metrics export (Prometheus, StatsD)
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cronwatch",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lightweight cron job tracker, debugger, and monitor for Node.js applications",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"types": "types/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"src/",
|
|
9
|
+
"types/"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"example": "node examples/basic.js",
|
|
13
|
+
"example:cron": "node examples/with-node-cron.js",
|
|
14
|
+
"test": "node --test test/"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"cron",
|
|
18
|
+
"scheduler",
|
|
19
|
+
"monitor",
|
|
20
|
+
"tracking",
|
|
21
|
+
"jobs",
|
|
22
|
+
"retry",
|
|
23
|
+
"logging",
|
|
24
|
+
"debug"
|
|
25
|
+
],
|
|
26
|
+
"author": "Manav Dadwal",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18.0.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {},
|
|
32
|
+
"dependencies": {}
|
|
33
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const LOG_LEVELS = Object.freeze({
|
|
4
|
+
DEBUG: 0,
|
|
5
|
+
INFO: 1,
|
|
6
|
+
WARN: 2,
|
|
7
|
+
ERROR: 3,
|
|
8
|
+
SILENT: 4,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const DEFAULTS = Object.freeze({
|
|
12
|
+
retries: 0,
|
|
13
|
+
retryDelay: 1000,
|
|
14
|
+
retryBackoff: 'fixed',
|
|
15
|
+
timeout: 0,
|
|
16
|
+
logLevel: LOG_LEVELS.INFO,
|
|
17
|
+
storeMaxEntries: 1000,
|
|
18
|
+
timestamps: true,
|
|
19
|
+
colorize: true,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
class Config {
|
|
23
|
+
#settings;
|
|
24
|
+
|
|
25
|
+
constructor(overrides = {}) {
|
|
26
|
+
this.#settings = { ...DEFAULTS, ...stripUndefined(overrides) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get(key) {
|
|
30
|
+
return this.#settings[key];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
set(key, value) {
|
|
34
|
+
this.#settings[key] = value;
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
merge(overrides = {}) {
|
|
39
|
+
return new Config({ ...this.#settings, ...stripUndefined(overrides) });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
toJSON() {
|
|
43
|
+
return { ...this.#settings };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stripUndefined(obj) {
|
|
48
|
+
const clean = {};
|
|
49
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
50
|
+
if (v !== undefined) clean[k] = v;
|
|
51
|
+
}
|
|
52
|
+
return clean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { Config, LOG_LEVELS, DEFAULTS };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Config, LOG_LEVELS, DEFAULTS } = require('./config');
|
|
4
|
+
const { Logger } = require('./logger');
|
|
5
|
+
const { Store, StoreAdapter, MemoryAdapter } = require('./store');
|
|
6
|
+
const { PluginManager, HOOK_NAMES } = require('./plugin');
|
|
7
|
+
const { trackJob: _trackJob, TimeoutError } = require('./tracker');
|
|
8
|
+
const { BACKOFF_STRATEGIES } = require('./retry');
|
|
9
|
+
|
|
10
|
+
class CronWatch {
|
|
11
|
+
#config;
|
|
12
|
+
#logger;
|
|
13
|
+
#store;
|
|
14
|
+
#plugins;
|
|
15
|
+
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this.#config = new Config(options);
|
|
18
|
+
this.#logger = new Logger(this.#config);
|
|
19
|
+
this.#store = new Store(
|
|
20
|
+
options.storeAdapter || new MemoryAdapter(this.#config.get('storeMaxEntries'))
|
|
21
|
+
);
|
|
22
|
+
this.#plugins = new PluginManager();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async trackJob(jobName, jobFn, options = {}) {
|
|
26
|
+
return _trackJob(jobName, jobFn, options, {
|
|
27
|
+
logger: this.#logger,
|
|
28
|
+
store: this.#store,
|
|
29
|
+
pluginManager: this.#plugins,
|
|
30
|
+
config: this.#config,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
use(plugin) {
|
|
35
|
+
this.#plugins.register(plugin);
|
|
36
|
+
this.#logger.debug(`Plugin registered: ${plugin.name}`);
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async getJobLogs(jobName) {
|
|
41
|
+
return this.#store.getByJob(jobName);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async getAllLogs() {
|
|
45
|
+
return this.#store.getAll();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async clearLogs() {
|
|
49
|
+
await this.#store.clear();
|
|
50
|
+
this.#logger.debug('All logs cleared');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get config() {
|
|
54
|
+
return this.#config;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get logger() {
|
|
58
|
+
return this.#logger;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get plugins() {
|
|
62
|
+
return this.#plugins.plugins;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createCronWatch(options = {}) {
|
|
67
|
+
return new CronWatch(options);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
CronWatch,
|
|
72
|
+
createCronWatch,
|
|
73
|
+
Config,
|
|
74
|
+
Logger,
|
|
75
|
+
Store,
|
|
76
|
+
StoreAdapter,
|
|
77
|
+
MemoryAdapter,
|
|
78
|
+
PluginManager,
|
|
79
|
+
TimeoutError,
|
|
80
|
+
LOG_LEVELS,
|
|
81
|
+
DEFAULTS,
|
|
82
|
+
HOOK_NAMES,
|
|
83
|
+
BACKOFF_STRATEGIES,
|
|
84
|
+
};
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { LOG_LEVELS } = require('./config');
|
|
4
|
+
|
|
5
|
+
const COLORS = {
|
|
6
|
+
reset: '\x1b[0m',
|
|
7
|
+
dim: '\x1b[2m',
|
|
8
|
+
green: '\x1b[32m',
|
|
9
|
+
yellow: '\x1b[33m',
|
|
10
|
+
red: '\x1b[31m',
|
|
11
|
+
cyan: '\x1b[36m',
|
|
12
|
+
magenta: '\x1b[35m',
|
|
13
|
+
gray: '\x1b[90m',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const LEVEL_TAGS = {
|
|
17
|
+
[LOG_LEVELS.DEBUG]: { label: 'DEBUG', color: COLORS.gray },
|
|
18
|
+
[LOG_LEVELS.INFO]: { label: 'INFO ', color: COLORS.cyan },
|
|
19
|
+
[LOG_LEVELS.WARN]: { label: 'WARN ', color: COLORS.yellow },
|
|
20
|
+
[LOG_LEVELS.ERROR]: { label: 'ERROR', color: COLORS.red },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
class Logger {
|
|
24
|
+
#level;
|
|
25
|
+
#colorize;
|
|
26
|
+
#timestamps;
|
|
27
|
+
#outputs;
|
|
28
|
+
|
|
29
|
+
constructor(config) {
|
|
30
|
+
this.#level = config.get('logLevel');
|
|
31
|
+
this.#colorize = config.get('colorize');
|
|
32
|
+
this.#timestamps = config.get('timestamps');
|
|
33
|
+
this.#outputs = [consoleOutput];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
addOutput(fn) {
|
|
37
|
+
if (typeof fn === 'function') this.#outputs.push(fn);
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
debug(msg, meta) { this.#log(LOG_LEVELS.DEBUG, msg, meta); }
|
|
42
|
+
info(msg, meta) { this.#log(LOG_LEVELS.INFO, msg, meta); }
|
|
43
|
+
warn(msg, meta) { this.#log(LOG_LEVELS.WARN, msg, meta); }
|
|
44
|
+
error(msg, meta) { this.#log(LOG_LEVELS.ERROR, msg, meta); }
|
|
45
|
+
|
|
46
|
+
#log(level, msg, meta) {
|
|
47
|
+
if (level < this.#level) return;
|
|
48
|
+
|
|
49
|
+
const entry = {
|
|
50
|
+
level,
|
|
51
|
+
label: LEVEL_TAGS[level]?.label || 'LOG',
|
|
52
|
+
timestamp: this.#timestamps ? new Date().toISOString() : null,
|
|
53
|
+
message: msg,
|
|
54
|
+
meta: meta || null,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const formatted = this.#format(entry);
|
|
58
|
+
for (const output of this.#outputs) {
|
|
59
|
+
output(entry, formatted);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#format(entry) {
|
|
64
|
+
const c = this.#colorize ? COLORS : noColors();
|
|
65
|
+
const tag = LEVEL_TAGS[entry.level] || { label: 'LOG', color: '' };
|
|
66
|
+
const color = this.#colorize ? tag.color : '';
|
|
67
|
+
|
|
68
|
+
const parts = [];
|
|
69
|
+
if (entry.timestamp) {
|
|
70
|
+
parts.push(`${c.dim}${entry.timestamp}${c.reset}`);
|
|
71
|
+
}
|
|
72
|
+
parts.push(`${color}[${entry.label}]${c.reset}`);
|
|
73
|
+
parts.push(`${c.magenta}[CronWatch]${c.reset}`);
|
|
74
|
+
parts.push(entry.message);
|
|
75
|
+
|
|
76
|
+
return parts.join(' ');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function consoleOutput(entry, formatted) {
|
|
81
|
+
const stream = entry.level >= LOG_LEVELS.ERROR ? console.error : console.log;
|
|
82
|
+
stream(formatted);
|
|
83
|
+
if (entry.meta) {
|
|
84
|
+
stream(
|
|
85
|
+
entry.level >= LOG_LEVELS.ERROR
|
|
86
|
+
? entry.meta
|
|
87
|
+
: JSON.stringify(entry.meta, null, 2)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function noColors() {
|
|
93
|
+
const empty = {};
|
|
94
|
+
for (const key of Object.keys(COLORS)) empty[key] = '';
|
|
95
|
+
return empty;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { Logger, LOG_LEVELS };
|
package/src/plugin.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const HOOK_NAMES = ['onStart', 'onSuccess', 'onFailure', 'onRetry', 'onTimeout'];
|
|
4
|
+
|
|
5
|
+
class PluginManager {
|
|
6
|
+
#plugins;
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
this.#plugins = [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
register(plugin) {
|
|
13
|
+
if (!plugin || typeof plugin !== 'object') {
|
|
14
|
+
throw new TypeError('Plugin must be an object');
|
|
15
|
+
}
|
|
16
|
+
if (!plugin.name || typeof plugin.name !== 'string') {
|
|
17
|
+
throw new TypeError('Plugin must have a string "name" property');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const hasHook = HOOK_NAMES.some((h) => typeof plugin[h] === 'function');
|
|
21
|
+
if (!hasHook) {
|
|
22
|
+
throw new TypeError(
|
|
23
|
+
`Plugin "${plugin.name}" must implement at least one hook: ${HOOK_NAMES.join(', ')}`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.#plugins.push(plugin);
|
|
28
|
+
return this;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async emit(hookName, context) {
|
|
32
|
+
if (!HOOK_NAMES.includes(hookName)) return;
|
|
33
|
+
|
|
34
|
+
for (const plugin of this.#plugins) {
|
|
35
|
+
if (typeof plugin[hookName] === 'function') {
|
|
36
|
+
try {
|
|
37
|
+
await plugin[hookName](context);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error(`[CronWatch] Plugin "${plugin.name}" threw in ${hookName}:`, err);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get plugins() {
|
|
46
|
+
return this.#plugins.map((p) => p.name);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { PluginManager, HOOK_NAMES };
|
package/src/retry.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const BACKOFF_STRATEGIES = {
|
|
4
|
+
fixed: (delay, _attempt) => delay,
|
|
5
|
+
linear: (delay, attempt) => delay * attempt,
|
|
6
|
+
exponential: (delay, attempt) => delay * Math.pow(2, attempt - 1),
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function getDelay(strategy, baseDelay, attempt) {
|
|
10
|
+
const calc = BACKOFF_STRATEGIES[strategy] || BACKOFF_STRATEGIES.fixed;
|
|
11
|
+
return calc(baseDelay, attempt);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sleep(ms) {
|
|
15
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function withRetry(fn, options = {}, hooks = {}) {
|
|
19
|
+
const maxRetries = options.retries ?? 0;
|
|
20
|
+
const baseDelay = options.retryDelay ?? 1000;
|
|
21
|
+
const strategy = options.retryBackoff ?? 'fixed';
|
|
22
|
+
|
|
23
|
+
let lastError;
|
|
24
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
25
|
+
try {
|
|
26
|
+
return await fn();
|
|
27
|
+
} catch (err) {
|
|
28
|
+
lastError = err;
|
|
29
|
+
|
|
30
|
+
if (attempt < maxRetries) {
|
|
31
|
+
const delay = getDelay(strategy, baseDelay, attempt + 1);
|
|
32
|
+
if (hooks.onRetry) {
|
|
33
|
+
await hooks.onRetry({ attempt: attempt + 1, maxRetries, delay, error: err });
|
|
34
|
+
}
|
|
35
|
+
await sleep(delay);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
throw lastError;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { withRetry, BACKOFF_STRATEGIES };
|
package/src/store.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pluggable storage adapter interface.
|
|
5
|
+
*
|
|
6
|
+
* Any custom adapter must implement:
|
|
7
|
+
* save(entry) -> Promise<void>
|
|
8
|
+
* getByJob(jobName) -> Promise<Array>
|
|
9
|
+
* getAll() -> Promise<Array>
|
|
10
|
+
* clear() -> Promise<void>
|
|
11
|
+
*/
|
|
12
|
+
class StoreAdapter {
|
|
13
|
+
async save(_entry) { throw new Error('save() not implemented'); }
|
|
14
|
+
async getByJob(_jobName) { throw new Error('getByJob() not implemented'); }
|
|
15
|
+
async getAll() { throw new Error('getAll() not implemented'); }
|
|
16
|
+
async clear() { throw new Error('clear() not implemented'); }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class MemoryAdapter extends StoreAdapter {
|
|
20
|
+
#entries;
|
|
21
|
+
#maxEntries;
|
|
22
|
+
|
|
23
|
+
constructor(maxEntries = 1000) {
|
|
24
|
+
super();
|
|
25
|
+
this.#entries = [];
|
|
26
|
+
this.#maxEntries = maxEntries;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async save(entry) {
|
|
30
|
+
this.#entries.push(Object.freeze({ ...entry }));
|
|
31
|
+
if (this.#entries.length > this.#maxEntries) {
|
|
32
|
+
this.#entries = this.#entries.slice(-this.#maxEntries);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async getByJob(jobName) {
|
|
37
|
+
return this.#entries.filter((e) => e.jobName === jobName);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async getAll() {
|
|
41
|
+
return [...this.#entries];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async clear() {
|
|
45
|
+
this.#entries = [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get size() {
|
|
49
|
+
return this.#entries.length;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class Store {
|
|
54
|
+
#adapter;
|
|
55
|
+
|
|
56
|
+
constructor(adapter) {
|
|
57
|
+
if (adapter && !(adapter instanceof StoreAdapter)) {
|
|
58
|
+
throw new TypeError('Store adapter must extend StoreAdapter');
|
|
59
|
+
}
|
|
60
|
+
this.#adapter = adapter || new MemoryAdapter();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
save(entry) { return this.#adapter.save(entry); }
|
|
64
|
+
getByJob(jobName) { return this.#adapter.getByJob(jobName); }
|
|
65
|
+
getAll() { return this.#adapter.getAll(); }
|
|
66
|
+
clear() { return this.#adapter.clear(); }
|
|
67
|
+
|
|
68
|
+
get adapter() { return this.#adapter; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { Store, StoreAdapter, MemoryAdapter };
|
package/src/tracker.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { withRetry } = require('./retry');
|
|
4
|
+
|
|
5
|
+
class TimeoutError extends Error {
|
|
6
|
+
constructor(jobName, ms) {
|
|
7
|
+
super(`Job "${jobName}" timed out after ${ms}ms`);
|
|
8
|
+
this.name = 'TimeoutError';
|
|
9
|
+
this.code = 'ERR_JOB_TIMEOUT';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function withTimeout(fn, ms, jobName) {
|
|
14
|
+
if (!ms || ms <= 0) return fn();
|
|
15
|
+
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const timer = setTimeout(() => {
|
|
18
|
+
reject(new TimeoutError(jobName, ms));
|
|
19
|
+
}, ms);
|
|
20
|
+
|
|
21
|
+
fn()
|
|
22
|
+
.then((val) => { clearTimeout(timer); resolve(val); })
|
|
23
|
+
.catch((err) => { clearTimeout(timer); reject(err); });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildEntry(jobName, status, startTime, endTime, error, retryCount) {
|
|
28
|
+
return {
|
|
29
|
+
jobName,
|
|
30
|
+
status,
|
|
31
|
+
startTime: startTime.toISOString(),
|
|
32
|
+
endTime: endTime.toISOString(),
|
|
33
|
+
duration: endTime - startTime,
|
|
34
|
+
error: error
|
|
35
|
+
? { message: error.message, stack: error.stack, code: error.code || null }
|
|
36
|
+
: null,
|
|
37
|
+
retries: retryCount,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function trackJob(jobName, jobFn, opts = {}, deps) {
|
|
42
|
+
const { logger, store, pluginManager, config } = deps;
|
|
43
|
+
|
|
44
|
+
if (typeof jobName !== 'string' || !jobName.trim()) {
|
|
45
|
+
throw new TypeError('jobName must be a non-empty string');
|
|
46
|
+
}
|
|
47
|
+
if (typeof jobFn !== 'function') {
|
|
48
|
+
throw new TypeError('jobFn must be a function');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const merged = config.merge(opts);
|
|
52
|
+
const timeout = merged.get('timeout');
|
|
53
|
+
const maxRetries = merged.get('retries');
|
|
54
|
+
|
|
55
|
+
let retryCount = 0;
|
|
56
|
+
const startTime = new Date();
|
|
57
|
+
|
|
58
|
+
logger.info(`Starting job "${jobName}"`, maxRetries > 0 ? { maxRetries } : undefined);
|
|
59
|
+
await pluginManager.emit('onStart', { jobName, startTime, config: merged });
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const wrappedFn = () => {
|
|
63
|
+
const result = jobFn();
|
|
64
|
+
const promise = result && typeof result.then === 'function' ? result : Promise.resolve(result);
|
|
65
|
+
return timeout > 0 ? withTimeout(() => promise, timeout, jobName) : promise;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const result = await withRetry(wrappedFn, merged.toJSON(), {
|
|
69
|
+
onRetry: async ({ attempt, delay, error }) => {
|
|
70
|
+
retryCount = attempt;
|
|
71
|
+
logger.warn(`Retrying job "${jobName}" (${attempt}/${maxRetries}) after ${delay}ms`, {
|
|
72
|
+
error: error.message,
|
|
73
|
+
});
|
|
74
|
+
await pluginManager.emit('onRetry', {
|
|
75
|
+
jobName, attempt, maxRetries, delay, error,
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const endTime = new Date();
|
|
81
|
+
const entry = buildEntry(jobName, 'success', startTime, endTime, null, retryCount);
|
|
82
|
+
|
|
83
|
+
logger.info(`Job "${jobName}" completed in ${entry.duration}ms`);
|
|
84
|
+
await store.save(entry);
|
|
85
|
+
await pluginManager.emit('onSuccess', { jobName, entry, result });
|
|
86
|
+
|
|
87
|
+
return entry;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const endTime = new Date();
|
|
90
|
+
const isTimeout = error.name === 'TimeoutError';
|
|
91
|
+
const status = isTimeout ? 'timeout' : 'failure';
|
|
92
|
+
const entry = buildEntry(jobName, status, startTime, endTime, error, retryCount);
|
|
93
|
+
|
|
94
|
+
if (isTimeout) {
|
|
95
|
+
logger.error(`Job "${jobName}" timed out after ${timeout}ms`);
|
|
96
|
+
await pluginManager.emit('onTimeout', { jobName, entry, error });
|
|
97
|
+
} else {
|
|
98
|
+
logger.error(`Job "${jobName}" failed after ${retryCount} retries`, error);
|
|
99
|
+
await pluginManager.emit('onFailure', { jobName, entry, error });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await store.save(entry);
|
|
103
|
+
return entry;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { trackJob, TimeoutError };
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
export interface CronWatchOptions {
|
|
2
|
+
/** Max retry attempts (default: 0) */
|
|
3
|
+
retries?: number;
|
|
4
|
+
/** Base delay between retries in ms (default: 1000) */
|
|
5
|
+
retryDelay?: number;
|
|
6
|
+
/** Backoff strategy: 'fixed' | 'linear' | 'exponential' (default: 'fixed') */
|
|
7
|
+
retryBackoff?: 'fixed' | 'linear' | 'exponential';
|
|
8
|
+
/** Job timeout in ms, 0 = no timeout (default: 0) */
|
|
9
|
+
timeout?: number;
|
|
10
|
+
/** Minimum log level (default: LOG_LEVELS.INFO) */
|
|
11
|
+
logLevel?: number;
|
|
12
|
+
/** Max entries retained in memory store (default: 1000) */
|
|
13
|
+
storeMaxEntries?: number;
|
|
14
|
+
/** Include ISO timestamps in logs (default: true) */
|
|
15
|
+
timestamps?: boolean;
|
|
16
|
+
/** Colorize console output (default: true) */
|
|
17
|
+
colorize?: boolean;
|
|
18
|
+
/** Custom store adapter instance */
|
|
19
|
+
storeAdapter?: StoreAdapter;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface JobTrackOptions {
|
|
23
|
+
retries?: number;
|
|
24
|
+
retryDelay?: number;
|
|
25
|
+
retryBackoff?: 'fixed' | 'linear' | 'exponential';
|
|
26
|
+
timeout?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface JobEntry {
|
|
30
|
+
jobName: string;
|
|
31
|
+
status: 'success' | 'failure' | 'timeout';
|
|
32
|
+
startTime: string;
|
|
33
|
+
endTime: string;
|
|
34
|
+
duration: number;
|
|
35
|
+
error: JobError | null;
|
|
36
|
+
retries: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface JobError {
|
|
40
|
+
message: string;
|
|
41
|
+
stack: string;
|
|
42
|
+
code: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PluginContext {
|
|
46
|
+
jobName: string;
|
|
47
|
+
[key: string]: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CronWatchPlugin {
|
|
51
|
+
name: string;
|
|
52
|
+
onStart?(ctx: PluginContext): void | Promise<void>;
|
|
53
|
+
onSuccess?(ctx: PluginContext): void | Promise<void>;
|
|
54
|
+
onFailure?(ctx: PluginContext): void | Promise<void>;
|
|
55
|
+
onRetry?(ctx: PluginContext): void | Promise<void>;
|
|
56
|
+
onTimeout?(ctx: PluginContext): void | Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export declare class Config {
|
|
60
|
+
constructor(overrides?: Partial<CronWatchOptions>);
|
|
61
|
+
get<K extends keyof CronWatchOptions>(key: K): CronWatchOptions[K];
|
|
62
|
+
set<K extends keyof CronWatchOptions>(key: K, value: CronWatchOptions[K]): this;
|
|
63
|
+
merge(overrides?: Partial<CronWatchOptions>): Config;
|
|
64
|
+
toJSON(): CronWatchOptions;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export declare class Logger {
|
|
68
|
+
constructor(config: Config);
|
|
69
|
+
addOutput(fn: (entry: object, formatted: string) => void): this;
|
|
70
|
+
debug(msg: string, meta?: unknown): void;
|
|
71
|
+
info(msg: string, meta?: unknown): void;
|
|
72
|
+
warn(msg: string, meta?: unknown): void;
|
|
73
|
+
error(msg: string, meta?: unknown): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export declare class StoreAdapter {
|
|
77
|
+
save(entry: JobEntry): Promise<void>;
|
|
78
|
+
getByJob(jobName: string): Promise<JobEntry[]>;
|
|
79
|
+
getAll(): Promise<JobEntry[]>;
|
|
80
|
+
clear(): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export declare class MemoryAdapter extends StoreAdapter {
|
|
84
|
+
constructor(maxEntries?: number);
|
|
85
|
+
readonly size: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export declare class Store {
|
|
89
|
+
constructor(adapter?: StoreAdapter);
|
|
90
|
+
save(entry: JobEntry): Promise<void>;
|
|
91
|
+
getByJob(jobName: string): Promise<JobEntry[]>;
|
|
92
|
+
getAll(): Promise<JobEntry[]>;
|
|
93
|
+
clear(): Promise<void>;
|
|
94
|
+
readonly adapter: StoreAdapter;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export declare class PluginManager {
|
|
98
|
+
register(plugin: CronWatchPlugin): this;
|
|
99
|
+
emit(hookName: string, context: PluginContext): Promise<void>;
|
|
100
|
+
readonly plugins: string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export declare class TimeoutError extends Error {
|
|
104
|
+
readonly code: string;
|
|
105
|
+
constructor(jobName: string, ms: number);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export declare class CronWatch {
|
|
109
|
+
constructor(options?: CronWatchOptions);
|
|
110
|
+
trackJob(
|
|
111
|
+
jobName: string,
|
|
112
|
+
jobFn: () => unknown | Promise<unknown>,
|
|
113
|
+
options?: JobTrackOptions
|
|
114
|
+
): Promise<JobEntry>;
|
|
115
|
+
use(plugin: CronWatchPlugin): this;
|
|
116
|
+
getJobLogs(jobName: string): Promise<JobEntry[]>;
|
|
117
|
+
getAllLogs(): Promise<JobEntry[]>;
|
|
118
|
+
clearLogs(): Promise<void>;
|
|
119
|
+
readonly config: Config;
|
|
120
|
+
readonly logger: Logger;
|
|
121
|
+
readonly plugins: string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export declare function createCronWatch(options?: CronWatchOptions): CronWatch;
|
|
125
|
+
|
|
126
|
+
export declare const LOG_LEVELS: {
|
|
127
|
+
readonly DEBUG: 0;
|
|
128
|
+
readonly INFO: 1;
|
|
129
|
+
readonly WARN: 2;
|
|
130
|
+
readonly ERROR: 3;
|
|
131
|
+
readonly SILENT: 4;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export declare const DEFAULTS: Readonly<CronWatchOptions>;
|
|
135
|
+
|
|
136
|
+
export declare const HOOK_NAMES: readonly string[];
|
|
137
|
+
|
|
138
|
+
export declare const BACKOFF_STRATEGIES: {
|
|
139
|
+
readonly fixed: (delay: number, attempt: number) => number;
|
|
140
|
+
readonly linear: (delay: number, attempt: number) => number;
|
|
141
|
+
readonly exponential: (delay: number, attempt: number) => number;
|
|
142
|
+
};
|