@nixxie-cms/jobs 1.0.1
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/LICENSE +23 -0
- package/dist/declarations/src/JobsService.d.ts +27 -0
- package/dist/declarations/src/JobsService.d.ts.map +1 -0
- package/dist/declarations/src/index.d.ts +7 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/declarations/src/types.d.ts +28 -0
- package/dist/declarations/src/types.d.ts.map +1 -0
- package/dist/nixxie-cms-jobs.cjs.d.ts +2 -0
- package/dist/nixxie-cms-jobs.cjs.js +320 -0
- package/dist/nixxie-cms-jobs.esm.js +315 -0
- package/package.json +33 -0
- package/src/JobsService.ts +352 -0
- package/src/index.ts +10 -0
- package/src/types.ts +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nixxie International DMCC
|
|
4
|
+
Portions Copyright (c) 2023 Thinkmill Labs Pty Ltd and contributors
|
|
5
|
+
(this software is derived from the KeystoneJS project, https://keystonejs.com)
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { NixxieJobDefinition, NixxieJobInfo, NixxieJobsService } from '@nixxie-cms/core';
|
|
2
|
+
import type { JobsConfig } from "./types.js";
|
|
3
|
+
export declare class JobsService implements NixxieJobsService {
|
|
4
|
+
private jobs;
|
|
5
|
+
private config;
|
|
6
|
+
private cronTimer;
|
|
7
|
+
private cronStarted;
|
|
8
|
+
private lastCronMinute;
|
|
9
|
+
private cronFormatter?;
|
|
10
|
+
private closed;
|
|
11
|
+
constructor(config: JobsConfig);
|
|
12
|
+
/** Lazily-built (and reused) Intl formatter for the configured cron timezone, if any. */
|
|
13
|
+
private getCronFormatter;
|
|
14
|
+
define(job: NixxieJobDefinition): void;
|
|
15
|
+
remove(name: string): void;
|
|
16
|
+
trigger(name: string): Promise<void>;
|
|
17
|
+
list(): NixxieJobInfo[];
|
|
18
|
+
get(name: string): NixxieJobInfo | undefined;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
private schedule;
|
|
21
|
+
/** Compute the next time a job will run (exact for intervals, next matching minute for cron). */
|
|
22
|
+
private computeNextRun;
|
|
23
|
+
private startCronTick;
|
|
24
|
+
private runJob;
|
|
25
|
+
private clearTimer;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=JobsService.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"JobsService.d.ts","sourceRoot":"../../../src","sources":["JobsService.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AAC7F,OAAO,KAAK,EAAE,UAAU,EAAE,mBAAe;AAuGzC,qBAAa,WAAY,YAAW,iBAAiB;IACnD,OAAO,CAAC,IAAI,CAA8B;IAC1C,OAAO,CAAC,MAAM,CAAY;IAC1B,OAAO,CAAC,SAAS,CAA6C;IAC9D,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,aAAa,CAAC,CAAqB;IAC3C,OAAO,CAAC,MAAM,CAAQ;gBAEV,MAAM,EAAE,UAAU;IAQ9B,yFAAyF;IACzF,OAAO,CAAC,gBAAgB;IAgBxB,MAAM,CAAC,GAAG,EAAE,mBAAmB,GAAG,IAAI;IA2BtC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAOpB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAM1C,IAAI,IAAI,aAAa,EAAE;IAIvB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS;IAKtC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAsB5B,OAAO,CAAC,QAAQ;IAgBhB,iGAAiG;IACjG,OAAO,CAAC,cAAc;IAuBtB,OAAO,CAAC,aAAa;YAqCP,MAAM;IA2DpB,OAAO,CAAC,UAAU;CAMnB"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { JobsConfig } from "./types.js";
|
|
2
|
+
import { JobsService } from "./JobsService.js";
|
|
3
|
+
export declare function createJobs(config?: JobsConfig): JobsService;
|
|
4
|
+
export { JobsService };
|
|
5
|
+
export type { JobsConfig } from "./types.js";
|
|
6
|
+
export type { NixxieJobDefinition, NixxieJobInfo, NixxieJobsService } from '@nixxie-cms/core';
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"../../../src","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,mBAAe;AACzC,OAAO,EAAE,WAAW,EAAE,yBAAqB;AAE3C,wBAAgB,UAAU,CAAC,MAAM,GAAE,UAAe,GAAG,WAAW,CAE/D;AAED,OAAO,EAAE,WAAW,EAAE,CAAA;AACtB,YAAY,EAAE,UAAU,EAAE,mBAAe;AACzC,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { NixxieJobDefinition, NixxieJobInfo, NixxieJobsService } from '@nixxie-cms/core';
|
|
2
|
+
export type { NixxieJobDefinition, NixxieJobInfo, NixxieJobsService };
|
|
3
|
+
export type JobsConfig = {
|
|
4
|
+
/**
|
|
5
|
+
* Job definitions to register on start.
|
|
6
|
+
* Can also be registered later via `service.define()`.
|
|
7
|
+
*/
|
|
8
|
+
jobs?: NixxieJobDefinition[];
|
|
9
|
+
/**
|
|
10
|
+
* Timezone to use for cron expression evaluation.
|
|
11
|
+
* Defaults to the system timezone.
|
|
12
|
+
*/
|
|
13
|
+
timezone?: string;
|
|
14
|
+
/**
|
|
15
|
+
* How long (ms) to wait for in-flight jobs to finish on close().
|
|
16
|
+
* @default 5000
|
|
17
|
+
*/
|
|
18
|
+
shutdownTimeout?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Called when a job throws an error.
|
|
21
|
+
*/
|
|
22
|
+
onError?: (name: string, error: Error) => void;
|
|
23
|
+
/**
|
|
24
|
+
* Called after each successful job execution.
|
|
25
|
+
*/
|
|
26
|
+
onComplete?: (name: string, duration: number) => void;
|
|
27
|
+
};
|
|
28
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"../../../src","sources":["types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AAE7F,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,iBAAiB,EAAE,CAAA;AAErE,MAAM,MAAM,UAAU,GAAG;IACvB;;;OAGG;IACH,IAAI,CAAC,EAAE,mBAAmB,EAAE,CAAA;IAE5B;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;IAExB;;OAEG;IACH,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,KAAK,IAAI,CAAA;IAE9C;;OAEG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAA;CACtD,CAAA"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export * from "./declarations/src/index.js";
|
|
2
|
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy1qb2JzLmNqcy5kLnRzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi9kZWNsYXJhdGlvbnMvc3JjL2luZGV4LmQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEifQ==
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var _defineProperty = require('@babel/runtime/helpers/defineProperty');
|
|
6
|
+
|
|
7
|
+
function parseCron(expr) {
|
|
8
|
+
// Minimal cron parser: minute hour dom month dow
|
|
9
|
+
// Returns true if the given wall-clock parts match the expression
|
|
10
|
+
const parts = expr.trim().split(/\s+/);
|
|
11
|
+
if (parts.length !== 5) throw new Error(`Invalid cron expression: "${expr}"`);
|
|
12
|
+
const [minute, hour, dom, month, dow] = parts;
|
|
13
|
+
|
|
14
|
+
// Supported subset: *, literals, ranges (a-b), step (*/n, a-b/n, a/n) and
|
|
15
|
+
// comma-separated lists of any of the above. L/W/# are not supported.
|
|
16
|
+
function matches(field, value) {
|
|
17
|
+
// Comma list: any sub-expression matches
|
|
18
|
+
if (field.includes(',')) {
|
|
19
|
+
return field.split(',').some(part => matches(part, value));
|
|
20
|
+
}
|
|
21
|
+
// Step: base/step, where base is '*', a single number, or a range a-b
|
|
22
|
+
if (field.includes('/')) {
|
|
23
|
+
const [base, stepStr] = field.split('/');
|
|
24
|
+
const step = parseInt(stepStr, 10);
|
|
25
|
+
if (!Number.isFinite(step) || step <= 0) return false;
|
|
26
|
+
let from = 0;
|
|
27
|
+
let to = Infinity;
|
|
28
|
+
if (base !== '*' && base !== '') {
|
|
29
|
+
if (base.includes('-')) {
|
|
30
|
+
const [a, b] = base.split('-').map(Number);
|
|
31
|
+
from = a;
|
|
32
|
+
to = b;
|
|
33
|
+
} else {
|
|
34
|
+
from = parseInt(base, 10);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (value < from || value > to) return false;
|
|
38
|
+
return (value - from) % step === 0;
|
|
39
|
+
}
|
|
40
|
+
// Range: a-b
|
|
41
|
+
if (field.includes('-')) {
|
|
42
|
+
const [from, to] = field.split('-').map(Number);
|
|
43
|
+
return value >= from && value <= to;
|
|
44
|
+
}
|
|
45
|
+
// Wildcard
|
|
46
|
+
if (field === '*') return true;
|
|
47
|
+
// Literal
|
|
48
|
+
return parseInt(field, 10) === value;
|
|
49
|
+
}
|
|
50
|
+
return parts => matches(minute, parts.minute) && matches(hour, parts.hour) && matches(dom, parts.dom) && matches(month, parts.month) && matches(dow, parts.dow);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Extract the cron-relevant wall-clock parts of `now`. When a timezone formatter is given the
|
|
54
|
+
* parts are computed in that timezone; otherwise the server's local time is used.
|
|
55
|
+
*/
|
|
56
|
+
function cronPartsOf(now, fmt) {
|
|
57
|
+
var _weekdays$get;
|
|
58
|
+
if (!fmt) {
|
|
59
|
+
return {
|
|
60
|
+
minute: now.getMinutes(),
|
|
61
|
+
hour: now.getHours(),
|
|
62
|
+
dom: now.getDate(),
|
|
63
|
+
month: now.getMonth() + 1,
|
|
64
|
+
dow: now.getDay()
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const parts = fmt.formatToParts(now);
|
|
68
|
+
const get = type => {
|
|
69
|
+
var _parts$find$value, _parts$find;
|
|
70
|
+
return (_parts$find$value = (_parts$find = parts.find(p => p.type === type)) === null || _parts$find === void 0 ? void 0 : _parts$find.value) !== null && _parts$find$value !== void 0 ? _parts$find$value : '';
|
|
71
|
+
};
|
|
72
|
+
const weekdays = {
|
|
73
|
+
Sun: 0,
|
|
74
|
+
Mon: 1,
|
|
75
|
+
Tue: 2,
|
|
76
|
+
Wed: 3,
|
|
77
|
+
Thu: 4,
|
|
78
|
+
Fri: 5,
|
|
79
|
+
Sat: 6
|
|
80
|
+
};
|
|
81
|
+
let hour = parseInt(get('hour'), 10);
|
|
82
|
+
if (hour === 24) hour = 0; // some ICU builds render midnight as '24' under hour12:false
|
|
83
|
+
return {
|
|
84
|
+
minute: parseInt(get('minute'), 10),
|
|
85
|
+
hour,
|
|
86
|
+
dom: parseInt(get('day'), 10),
|
|
87
|
+
month: parseInt(get('month'), 10),
|
|
88
|
+
dow: (_weekdays$get = weekdays[get('weekday')]) !== null && _weekdays$get !== void 0 ? _weekdays$get : 0
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
class JobsService {
|
|
92
|
+
constructor(config) {
|
|
93
|
+
_defineProperty(this, "jobs", new Map());
|
|
94
|
+
_defineProperty(this, "cronTimer", null);
|
|
95
|
+
_defineProperty(this, "cronStarted", false);
|
|
96
|
+
_defineProperty(this, "lastCronMinute", -1);
|
|
97
|
+
_defineProperty(this, "closed", false);
|
|
98
|
+
this.config = config;
|
|
99
|
+
for (const job of (_config$jobs = config.jobs) !== null && _config$jobs !== void 0 ? _config$jobs : []) {
|
|
100
|
+
var _config$jobs;
|
|
101
|
+
this.define(job);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Lazily-built (and reused) Intl formatter for the configured cron timezone, if any. */
|
|
106
|
+
getCronFormatter() {
|
|
107
|
+
if (!this.config.timezone) return undefined;
|
|
108
|
+
if (!this.cronFormatter) {
|
|
109
|
+
this.cronFormatter = new Intl.DateTimeFormat('en-US', {
|
|
110
|
+
timeZone: this.config.timezone,
|
|
111
|
+
hour12: false,
|
|
112
|
+
weekday: 'short',
|
|
113
|
+
month: '2-digit',
|
|
114
|
+
day: '2-digit',
|
|
115
|
+
hour: '2-digit',
|
|
116
|
+
minute: '2-digit'
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return this.cronFormatter;
|
|
120
|
+
}
|
|
121
|
+
define(job) {
|
|
122
|
+
if (this.jobs.has(job.name)) {
|
|
123
|
+
this.remove(job.name);
|
|
124
|
+
}
|
|
125
|
+
const state = {
|
|
126
|
+
definition: job,
|
|
127
|
+
info: {
|
|
128
|
+
name: job.name,
|
|
129
|
+
status: job.enabled === false ? 'disabled' : 'idle',
|
|
130
|
+
runs: 0
|
|
131
|
+
},
|
|
132
|
+
timer: null,
|
|
133
|
+
running: false
|
|
134
|
+
};
|
|
135
|
+
this.jobs.set(job.name, state);
|
|
136
|
+
if (job.enabled !== false) {
|
|
137
|
+
this.schedule(state);
|
|
138
|
+
}
|
|
139
|
+
if (job.runImmediately) {
|
|
140
|
+
void this.runJob(state);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
remove(name) {
|
|
144
|
+
const state = this.jobs.get(name);
|
|
145
|
+
if (!state) return;
|
|
146
|
+
this.clearTimer(state);
|
|
147
|
+
this.jobs.delete(name);
|
|
148
|
+
}
|
|
149
|
+
async trigger(name) {
|
|
150
|
+
const state = this.jobs.get(name);
|
|
151
|
+
if (!state) throw new Error(`Job "${name}" is not registered`);
|
|
152
|
+
await this.runJob(state);
|
|
153
|
+
}
|
|
154
|
+
list() {
|
|
155
|
+
return Array.from(this.jobs.values()).map(s => ({
|
|
156
|
+
...s.info
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
get(name) {
|
|
160
|
+
const state = this.jobs.get(name);
|
|
161
|
+
return state ? {
|
|
162
|
+
...state.info
|
|
163
|
+
} : undefined;
|
|
164
|
+
}
|
|
165
|
+
async close() {
|
|
166
|
+
var _this$config$shutdown;
|
|
167
|
+
this.closed = true;
|
|
168
|
+
if (this.cronTimer) {
|
|
169
|
+
clearTimeout(this.cronTimer);
|
|
170
|
+
this.cronTimer = null;
|
|
171
|
+
}
|
|
172
|
+
this.cronStarted = false;
|
|
173
|
+
for (const state of this.jobs.values()) {
|
|
174
|
+
this.clearTimer(state);
|
|
175
|
+
}
|
|
176
|
+
const timeout = (_this$config$shutdown = this.config.shutdownTimeout) !== null && _this$config$shutdown !== void 0 ? _this$config$shutdown : 5000;
|
|
177
|
+
const deadline = Date.now() + timeout;
|
|
178
|
+
|
|
179
|
+
// Wait for running jobs
|
|
180
|
+
while (Date.now() < deadline) {
|
|
181
|
+
const anyRunning = Array.from(this.jobs.values()).some(s => s.running);
|
|
182
|
+
if (!anyRunning) break;
|
|
183
|
+
await new Promise(r => setTimeout(r, 100));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
schedule(state) {
|
|
187
|
+
const {
|
|
188
|
+
definition
|
|
189
|
+
} = state;
|
|
190
|
+
const schedule = definition.schedule;
|
|
191
|
+
if (typeof schedule === 'number') {
|
|
192
|
+
// Interval in ms
|
|
193
|
+
state.timer = setInterval(() => void this.runJob(state), schedule);
|
|
194
|
+
} else {
|
|
195
|
+
// Cron expression — a single shared per-minute ticker drives all cron jobs.
|
|
196
|
+
if (!this.cronStarted) {
|
|
197
|
+
this.startCronTick();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
state.info.nextRun = this.computeNextRun(state);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Compute the next time a job will run (exact for intervals, next matching minute for cron). */
|
|
204
|
+
computeNextRun(state) {
|
|
205
|
+
const schedule = state.definition.schedule;
|
|
206
|
+
if (typeof schedule === 'number') {
|
|
207
|
+
return new Date(Date.now() + schedule);
|
|
208
|
+
}
|
|
209
|
+
let matcher;
|
|
210
|
+
try {
|
|
211
|
+
matcher = parseCron(schedule);
|
|
212
|
+
} catch {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
const fmt = this.getCronFormatter();
|
|
216
|
+
const start = new Date();
|
|
217
|
+
start.setSeconds(0, 0);
|
|
218
|
+
// Search minute-by-minute up to ~366 days ahead (covers yearly schedules). The loop exits on
|
|
219
|
+
// the first match, so frequent schedules are cheap.
|
|
220
|
+
for (let i = 1; i <= 366 * 24 * 60; i++) {
|
|
221
|
+
const candidate = new Date(start.getTime() + i * 60000);
|
|
222
|
+
if (matcher(cronPartsOf(candidate, fmt))) return candidate;
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
startCronTick() {
|
|
227
|
+
this.cronStarted = true;
|
|
228
|
+
const scheduleNext = () => {
|
|
229
|
+
if (this.closed) return;
|
|
230
|
+
// Re-arm to the next minute boundary every tick. A fixed `setInterval(…, 60_000)` accumulates
|
|
231
|
+
// drift and can eventually skip a whole minute, silently losing a cron execution.
|
|
232
|
+
const ms = 60000 - Date.now() % 60000;
|
|
233
|
+
this.cronTimer = setTimeout(tick, ms);
|
|
234
|
+
};
|
|
235
|
+
const tick = () => {
|
|
236
|
+
this.cronTimer = null;
|
|
237
|
+
if (this.closed) return;
|
|
238
|
+
const now = new Date();
|
|
239
|
+
const minuteKey = Math.floor(now.getTime() / 60000);
|
|
240
|
+
// Only evaluate each wall-clock minute once, even if a tick fires slightly late/twice.
|
|
241
|
+
if (minuteKey !== this.lastCronMinute) {
|
|
242
|
+
this.lastCronMinute = minuteKey;
|
|
243
|
+
const parts = cronPartsOf(now, this.getCronFormatter());
|
|
244
|
+
for (const state of this.jobs.values()) {
|
|
245
|
+
if (typeof state.definition.schedule !== 'string') continue;
|
|
246
|
+
if (state.info.status === 'disabled' || state.running) continue;
|
|
247
|
+
try {
|
|
248
|
+
const matcher = parseCron(state.definition.schedule);
|
|
249
|
+
if (matcher(parts)) void this.runJob(state);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
console.error(`[nixxie/jobs] Invalid cron for "${state.definition.name}":`, err);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
scheduleNext();
|
|
256
|
+
};
|
|
257
|
+
scheduleNext();
|
|
258
|
+
}
|
|
259
|
+
async runJob(state) {
|
|
260
|
+
if (state.running || this.closed) return;
|
|
261
|
+
state.running = true;
|
|
262
|
+
state.info.status = 'running';
|
|
263
|
+
const start = Date.now();
|
|
264
|
+
const scheduledAt = new Date();
|
|
265
|
+
const run = Promise.resolve(state.definition.handler({
|
|
266
|
+
jobId: state.definition.name,
|
|
267
|
+
scheduledAt
|
|
268
|
+
}));
|
|
269
|
+
try {
|
|
270
|
+
var _this$config$onComple, _this$config;
|
|
271
|
+
const timeoutMs = state.definition.timeout;
|
|
272
|
+
if (timeoutMs) {
|
|
273
|
+
let timer;
|
|
274
|
+
try {
|
|
275
|
+
await Promise.race([run, new Promise((_, reject) => {
|
|
276
|
+
timer = setTimeout(() => reject(new Error(`Job "${state.definition.name}" timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
277
|
+
})]);
|
|
278
|
+
} finally {
|
|
279
|
+
if (timer) clearTimeout(timer);
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
await run;
|
|
283
|
+
}
|
|
284
|
+
state.info.status = 'idle';
|
|
285
|
+
state.info.lastRun = new Date();
|
|
286
|
+
state.info.runs++;
|
|
287
|
+
state.info.lastError = undefined;
|
|
288
|
+
(_this$config$onComple = (_this$config = this.config).onComplete) === null || _this$config$onComple === void 0 || _this$config$onComple.call(_this$config, state.definition.name, Date.now() - start);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
var _this$config$onError, _this$config2;
|
|
291
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
292
|
+
state.info.status = 'error';
|
|
293
|
+
state.info.lastRun = new Date();
|
|
294
|
+
state.info.lastError = error.message;
|
|
295
|
+
(_this$config$onError = (_this$config2 = this.config).onError) === null || _this$config$onError === void 0 || _this$config$onError.call(_this$config2, state.definition.name, error);
|
|
296
|
+
console.error(`[nixxie/jobs] Job "${state.definition.name}" failed:`, error);
|
|
297
|
+
} finally {
|
|
298
|
+
// Release the overlap lock only once the handler actually settles. On timeout we stopped
|
|
299
|
+
// *waiting* for it, but it may still be running in the background — releasing synchronously
|
|
300
|
+
// would let the next tick start a second concurrent run. (Normally `run` is already settled.)
|
|
301
|
+
void run.catch(() => {}).then(() => {
|
|
302
|
+
state.running = false;
|
|
303
|
+
if (!this.closed) state.info.nextRun = this.computeNextRun(state);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
clearTimer(state) {
|
|
308
|
+
if (state.timer) {
|
|
309
|
+
clearInterval(state.timer);
|
|
310
|
+
state.timer = null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function createJobs(config = {}) {
|
|
316
|
+
return new JobsService(config);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
exports.JobsService = JobsService;
|
|
320
|
+
exports.createJobs = createJobs;
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import _defineProperty from '@babel/runtime/helpers/esm/defineProperty';
|
|
2
|
+
|
|
3
|
+
function parseCron(expr) {
|
|
4
|
+
// Minimal cron parser: minute hour dom month dow
|
|
5
|
+
// Returns true if the given wall-clock parts match the expression
|
|
6
|
+
const parts = expr.trim().split(/\s+/);
|
|
7
|
+
if (parts.length !== 5) throw new Error(`Invalid cron expression: "${expr}"`);
|
|
8
|
+
const [minute, hour, dom, month, dow] = parts;
|
|
9
|
+
|
|
10
|
+
// Supported subset: *, literals, ranges (a-b), step (*/n, a-b/n, a/n) and
|
|
11
|
+
// comma-separated lists of any of the above. L/W/# are not supported.
|
|
12
|
+
function matches(field, value) {
|
|
13
|
+
// Comma list: any sub-expression matches
|
|
14
|
+
if (field.includes(',')) {
|
|
15
|
+
return field.split(',').some(part => matches(part, value));
|
|
16
|
+
}
|
|
17
|
+
// Step: base/step, where base is '*', a single number, or a range a-b
|
|
18
|
+
if (field.includes('/')) {
|
|
19
|
+
const [base, stepStr] = field.split('/');
|
|
20
|
+
const step = parseInt(stepStr, 10);
|
|
21
|
+
if (!Number.isFinite(step) || step <= 0) return false;
|
|
22
|
+
let from = 0;
|
|
23
|
+
let to = Infinity;
|
|
24
|
+
if (base !== '*' && base !== '') {
|
|
25
|
+
if (base.includes('-')) {
|
|
26
|
+
const [a, b] = base.split('-').map(Number);
|
|
27
|
+
from = a;
|
|
28
|
+
to = b;
|
|
29
|
+
} else {
|
|
30
|
+
from = parseInt(base, 10);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (value < from || value > to) return false;
|
|
34
|
+
return (value - from) % step === 0;
|
|
35
|
+
}
|
|
36
|
+
// Range: a-b
|
|
37
|
+
if (field.includes('-')) {
|
|
38
|
+
const [from, to] = field.split('-').map(Number);
|
|
39
|
+
return value >= from && value <= to;
|
|
40
|
+
}
|
|
41
|
+
// Wildcard
|
|
42
|
+
if (field === '*') return true;
|
|
43
|
+
// Literal
|
|
44
|
+
return parseInt(field, 10) === value;
|
|
45
|
+
}
|
|
46
|
+
return parts => matches(minute, parts.minute) && matches(hour, parts.hour) && matches(dom, parts.dom) && matches(month, parts.month) && matches(dow, parts.dow);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Extract the cron-relevant wall-clock parts of `now`. When a timezone formatter is given the
|
|
50
|
+
* parts are computed in that timezone; otherwise the server's local time is used.
|
|
51
|
+
*/
|
|
52
|
+
function cronPartsOf(now, fmt) {
|
|
53
|
+
var _weekdays$get;
|
|
54
|
+
if (!fmt) {
|
|
55
|
+
return {
|
|
56
|
+
minute: now.getMinutes(),
|
|
57
|
+
hour: now.getHours(),
|
|
58
|
+
dom: now.getDate(),
|
|
59
|
+
month: now.getMonth() + 1,
|
|
60
|
+
dow: now.getDay()
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const parts = fmt.formatToParts(now);
|
|
64
|
+
const get = type => {
|
|
65
|
+
var _parts$find$value, _parts$find;
|
|
66
|
+
return (_parts$find$value = (_parts$find = parts.find(p => p.type === type)) === null || _parts$find === void 0 ? void 0 : _parts$find.value) !== null && _parts$find$value !== void 0 ? _parts$find$value : '';
|
|
67
|
+
};
|
|
68
|
+
const weekdays = {
|
|
69
|
+
Sun: 0,
|
|
70
|
+
Mon: 1,
|
|
71
|
+
Tue: 2,
|
|
72
|
+
Wed: 3,
|
|
73
|
+
Thu: 4,
|
|
74
|
+
Fri: 5,
|
|
75
|
+
Sat: 6
|
|
76
|
+
};
|
|
77
|
+
let hour = parseInt(get('hour'), 10);
|
|
78
|
+
if (hour === 24) hour = 0; // some ICU builds render midnight as '24' under hour12:false
|
|
79
|
+
return {
|
|
80
|
+
minute: parseInt(get('minute'), 10),
|
|
81
|
+
hour,
|
|
82
|
+
dom: parseInt(get('day'), 10),
|
|
83
|
+
month: parseInt(get('month'), 10),
|
|
84
|
+
dow: (_weekdays$get = weekdays[get('weekday')]) !== null && _weekdays$get !== void 0 ? _weekdays$get : 0
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
class JobsService {
|
|
88
|
+
constructor(config) {
|
|
89
|
+
_defineProperty(this, "jobs", new Map());
|
|
90
|
+
_defineProperty(this, "cronTimer", null);
|
|
91
|
+
_defineProperty(this, "cronStarted", false);
|
|
92
|
+
_defineProperty(this, "lastCronMinute", -1);
|
|
93
|
+
_defineProperty(this, "closed", false);
|
|
94
|
+
this.config = config;
|
|
95
|
+
for (const job of (_config$jobs = config.jobs) !== null && _config$jobs !== void 0 ? _config$jobs : []) {
|
|
96
|
+
var _config$jobs;
|
|
97
|
+
this.define(job);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Lazily-built (and reused) Intl formatter for the configured cron timezone, if any. */
|
|
102
|
+
getCronFormatter() {
|
|
103
|
+
if (!this.config.timezone) return undefined;
|
|
104
|
+
if (!this.cronFormatter) {
|
|
105
|
+
this.cronFormatter = new Intl.DateTimeFormat('en-US', {
|
|
106
|
+
timeZone: this.config.timezone,
|
|
107
|
+
hour12: false,
|
|
108
|
+
weekday: 'short',
|
|
109
|
+
month: '2-digit',
|
|
110
|
+
day: '2-digit',
|
|
111
|
+
hour: '2-digit',
|
|
112
|
+
minute: '2-digit'
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return this.cronFormatter;
|
|
116
|
+
}
|
|
117
|
+
define(job) {
|
|
118
|
+
if (this.jobs.has(job.name)) {
|
|
119
|
+
this.remove(job.name);
|
|
120
|
+
}
|
|
121
|
+
const state = {
|
|
122
|
+
definition: job,
|
|
123
|
+
info: {
|
|
124
|
+
name: job.name,
|
|
125
|
+
status: job.enabled === false ? 'disabled' : 'idle',
|
|
126
|
+
runs: 0
|
|
127
|
+
},
|
|
128
|
+
timer: null,
|
|
129
|
+
running: false
|
|
130
|
+
};
|
|
131
|
+
this.jobs.set(job.name, state);
|
|
132
|
+
if (job.enabled !== false) {
|
|
133
|
+
this.schedule(state);
|
|
134
|
+
}
|
|
135
|
+
if (job.runImmediately) {
|
|
136
|
+
void this.runJob(state);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
remove(name) {
|
|
140
|
+
const state = this.jobs.get(name);
|
|
141
|
+
if (!state) return;
|
|
142
|
+
this.clearTimer(state);
|
|
143
|
+
this.jobs.delete(name);
|
|
144
|
+
}
|
|
145
|
+
async trigger(name) {
|
|
146
|
+
const state = this.jobs.get(name);
|
|
147
|
+
if (!state) throw new Error(`Job "${name}" is not registered`);
|
|
148
|
+
await this.runJob(state);
|
|
149
|
+
}
|
|
150
|
+
list() {
|
|
151
|
+
return Array.from(this.jobs.values()).map(s => ({
|
|
152
|
+
...s.info
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
get(name) {
|
|
156
|
+
const state = this.jobs.get(name);
|
|
157
|
+
return state ? {
|
|
158
|
+
...state.info
|
|
159
|
+
} : undefined;
|
|
160
|
+
}
|
|
161
|
+
async close() {
|
|
162
|
+
var _this$config$shutdown;
|
|
163
|
+
this.closed = true;
|
|
164
|
+
if (this.cronTimer) {
|
|
165
|
+
clearTimeout(this.cronTimer);
|
|
166
|
+
this.cronTimer = null;
|
|
167
|
+
}
|
|
168
|
+
this.cronStarted = false;
|
|
169
|
+
for (const state of this.jobs.values()) {
|
|
170
|
+
this.clearTimer(state);
|
|
171
|
+
}
|
|
172
|
+
const timeout = (_this$config$shutdown = this.config.shutdownTimeout) !== null && _this$config$shutdown !== void 0 ? _this$config$shutdown : 5000;
|
|
173
|
+
const deadline = Date.now() + timeout;
|
|
174
|
+
|
|
175
|
+
// Wait for running jobs
|
|
176
|
+
while (Date.now() < deadline) {
|
|
177
|
+
const anyRunning = Array.from(this.jobs.values()).some(s => s.running);
|
|
178
|
+
if (!anyRunning) break;
|
|
179
|
+
await new Promise(r => setTimeout(r, 100));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
schedule(state) {
|
|
183
|
+
const {
|
|
184
|
+
definition
|
|
185
|
+
} = state;
|
|
186
|
+
const schedule = definition.schedule;
|
|
187
|
+
if (typeof schedule === 'number') {
|
|
188
|
+
// Interval in ms
|
|
189
|
+
state.timer = setInterval(() => void this.runJob(state), schedule);
|
|
190
|
+
} else {
|
|
191
|
+
// Cron expression — a single shared per-minute ticker drives all cron jobs.
|
|
192
|
+
if (!this.cronStarted) {
|
|
193
|
+
this.startCronTick();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
state.info.nextRun = this.computeNextRun(state);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Compute the next time a job will run (exact for intervals, next matching minute for cron). */
|
|
200
|
+
computeNextRun(state) {
|
|
201
|
+
const schedule = state.definition.schedule;
|
|
202
|
+
if (typeof schedule === 'number') {
|
|
203
|
+
return new Date(Date.now() + schedule);
|
|
204
|
+
}
|
|
205
|
+
let matcher;
|
|
206
|
+
try {
|
|
207
|
+
matcher = parseCron(schedule);
|
|
208
|
+
} catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
const fmt = this.getCronFormatter();
|
|
212
|
+
const start = new Date();
|
|
213
|
+
start.setSeconds(0, 0);
|
|
214
|
+
// Search minute-by-minute up to ~366 days ahead (covers yearly schedules). The loop exits on
|
|
215
|
+
// the first match, so frequent schedules are cheap.
|
|
216
|
+
for (let i = 1; i <= 366 * 24 * 60; i++) {
|
|
217
|
+
const candidate = new Date(start.getTime() + i * 60000);
|
|
218
|
+
if (matcher(cronPartsOf(candidate, fmt))) return candidate;
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
startCronTick() {
|
|
223
|
+
this.cronStarted = true;
|
|
224
|
+
const scheduleNext = () => {
|
|
225
|
+
if (this.closed) return;
|
|
226
|
+
// Re-arm to the next minute boundary every tick. A fixed `setInterval(…, 60_000)` accumulates
|
|
227
|
+
// drift and can eventually skip a whole minute, silently losing a cron execution.
|
|
228
|
+
const ms = 60000 - Date.now() % 60000;
|
|
229
|
+
this.cronTimer = setTimeout(tick, ms);
|
|
230
|
+
};
|
|
231
|
+
const tick = () => {
|
|
232
|
+
this.cronTimer = null;
|
|
233
|
+
if (this.closed) return;
|
|
234
|
+
const now = new Date();
|
|
235
|
+
const minuteKey = Math.floor(now.getTime() / 60000);
|
|
236
|
+
// Only evaluate each wall-clock minute once, even if a tick fires slightly late/twice.
|
|
237
|
+
if (minuteKey !== this.lastCronMinute) {
|
|
238
|
+
this.lastCronMinute = minuteKey;
|
|
239
|
+
const parts = cronPartsOf(now, this.getCronFormatter());
|
|
240
|
+
for (const state of this.jobs.values()) {
|
|
241
|
+
if (typeof state.definition.schedule !== 'string') continue;
|
|
242
|
+
if (state.info.status === 'disabled' || state.running) continue;
|
|
243
|
+
try {
|
|
244
|
+
const matcher = parseCron(state.definition.schedule);
|
|
245
|
+
if (matcher(parts)) void this.runJob(state);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error(`[nixxie/jobs] Invalid cron for "${state.definition.name}":`, err);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
scheduleNext();
|
|
252
|
+
};
|
|
253
|
+
scheduleNext();
|
|
254
|
+
}
|
|
255
|
+
async runJob(state) {
|
|
256
|
+
if (state.running || this.closed) return;
|
|
257
|
+
state.running = true;
|
|
258
|
+
state.info.status = 'running';
|
|
259
|
+
const start = Date.now();
|
|
260
|
+
const scheduledAt = new Date();
|
|
261
|
+
const run = Promise.resolve(state.definition.handler({
|
|
262
|
+
jobId: state.definition.name,
|
|
263
|
+
scheduledAt
|
|
264
|
+
}));
|
|
265
|
+
try {
|
|
266
|
+
var _this$config$onComple, _this$config;
|
|
267
|
+
const timeoutMs = state.definition.timeout;
|
|
268
|
+
if (timeoutMs) {
|
|
269
|
+
let timer;
|
|
270
|
+
try {
|
|
271
|
+
await Promise.race([run, new Promise((_, reject) => {
|
|
272
|
+
timer = setTimeout(() => reject(new Error(`Job "${state.definition.name}" timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
273
|
+
})]);
|
|
274
|
+
} finally {
|
|
275
|
+
if (timer) clearTimeout(timer);
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
await run;
|
|
279
|
+
}
|
|
280
|
+
state.info.status = 'idle';
|
|
281
|
+
state.info.lastRun = new Date();
|
|
282
|
+
state.info.runs++;
|
|
283
|
+
state.info.lastError = undefined;
|
|
284
|
+
(_this$config$onComple = (_this$config = this.config).onComplete) === null || _this$config$onComple === void 0 || _this$config$onComple.call(_this$config, state.definition.name, Date.now() - start);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
var _this$config$onError, _this$config2;
|
|
287
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
288
|
+
state.info.status = 'error';
|
|
289
|
+
state.info.lastRun = new Date();
|
|
290
|
+
state.info.lastError = error.message;
|
|
291
|
+
(_this$config$onError = (_this$config2 = this.config).onError) === null || _this$config$onError === void 0 || _this$config$onError.call(_this$config2, state.definition.name, error);
|
|
292
|
+
console.error(`[nixxie/jobs] Job "${state.definition.name}" failed:`, error);
|
|
293
|
+
} finally {
|
|
294
|
+
// Release the overlap lock only once the handler actually settles. On timeout we stopped
|
|
295
|
+
// *waiting* for it, but it may still be running in the background — releasing synchronously
|
|
296
|
+
// would let the next tick start a second concurrent run. (Normally `run` is already settled.)
|
|
297
|
+
void run.catch(() => {}).then(() => {
|
|
298
|
+
state.running = false;
|
|
299
|
+
if (!this.closed) state.info.nextRun = this.computeNextRun(state);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
clearTimer(state) {
|
|
304
|
+
if (state.timer) {
|
|
305
|
+
clearInterval(state.timer);
|
|
306
|
+
state.timer = null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function createJobs(config = {}) {
|
|
312
|
+
return new JobsService(config);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export { JobsService, createJobs };
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nixxie-cms/jobs",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"main": "dist/nixxie-cms-jobs.cjs.js",
|
|
6
|
+
"module": "dist/nixxie-cms-jobs.esm.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/nixxie-cms-jobs.cjs.js",
|
|
10
|
+
"module": "./dist/nixxie-cms-jobs.esm.js",
|
|
11
|
+
"default": "./dist/nixxie-cms-jobs.cjs.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@babel/runtime": "^7.24.7"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@nixxie-cms/core": "^1.0.1"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@nixxie-cms/core": "^1.0.1"
|
|
23
|
+
},
|
|
24
|
+
"preconstruct": {
|
|
25
|
+
"entrypoints": [
|
|
26
|
+
"index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/nixxiecms/nixxie/tree/main/packages/jobs"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import type { NixxieJobDefinition, NixxieJobInfo, NixxieJobsService } from '@nixxie-cms/core'
|
|
2
|
+
import type { JobsConfig } from './types'
|
|
3
|
+
|
|
4
|
+
type Timer = ReturnType<typeof setInterval> | ReturnType<typeof setTimeout>
|
|
5
|
+
|
|
6
|
+
type JobState = {
|
|
7
|
+
definition: NixxieJobDefinition
|
|
8
|
+
info: NixxieJobInfo
|
|
9
|
+
timer: Timer | null
|
|
10
|
+
running: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parseCron(expr: string): (parts: CronParts) => boolean {
|
|
14
|
+
// Minimal cron parser: minute hour dom month dow
|
|
15
|
+
// Returns true if the given wall-clock parts match the expression
|
|
16
|
+
const parts = expr.trim().split(/\s+/)
|
|
17
|
+
if (parts.length !== 5) throw new Error(`Invalid cron expression: "${expr}"`)
|
|
18
|
+
|
|
19
|
+
const [minute, hour, dom, month, dow] = parts
|
|
20
|
+
|
|
21
|
+
// Supported subset: *, literals, ranges (a-b), step (*/n, a-b/n, a/n) and
|
|
22
|
+
// comma-separated lists of any of the above. L/W/# are not supported.
|
|
23
|
+
function matches(field: string, value: number): boolean {
|
|
24
|
+
// Comma list: any sub-expression matches
|
|
25
|
+
if (field.includes(',')) {
|
|
26
|
+
return field.split(',').some(part => matches(part, value))
|
|
27
|
+
}
|
|
28
|
+
// Step: base/step, where base is '*', a single number, or a range a-b
|
|
29
|
+
if (field.includes('/')) {
|
|
30
|
+
const [base, stepStr] = field.split('/')
|
|
31
|
+
const step = parseInt(stepStr, 10)
|
|
32
|
+
if (!Number.isFinite(step) || step <= 0) return false
|
|
33
|
+
|
|
34
|
+
let from = 0
|
|
35
|
+
let to = Infinity
|
|
36
|
+
if (base !== '*' && base !== '') {
|
|
37
|
+
if (base.includes('-')) {
|
|
38
|
+
const [a, b] = base.split('-').map(Number)
|
|
39
|
+
from = a
|
|
40
|
+
to = b
|
|
41
|
+
} else {
|
|
42
|
+
from = parseInt(base, 10)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (value < from || value > to) return false
|
|
46
|
+
return (value - from) % step === 0
|
|
47
|
+
}
|
|
48
|
+
// Range: a-b
|
|
49
|
+
if (field.includes('-')) {
|
|
50
|
+
const [from, to] = field.split('-').map(Number)
|
|
51
|
+
return value >= from && value <= to
|
|
52
|
+
}
|
|
53
|
+
// Wildcard
|
|
54
|
+
if (field === '*') return true
|
|
55
|
+
// Literal
|
|
56
|
+
return parseInt(field, 10) === value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (parts: CronParts) =>
|
|
60
|
+
matches(minute, parts.minute) &&
|
|
61
|
+
matches(hour, parts.hour) &&
|
|
62
|
+
matches(dom, parts.dom) &&
|
|
63
|
+
matches(month, parts.month) &&
|
|
64
|
+
matches(dow, parts.dow)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type CronParts = { minute: number; hour: number; dom: number; month: number; dow: number }
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extract the cron-relevant wall-clock parts of `now`. When a timezone formatter is given the
|
|
71
|
+
* parts are computed in that timezone; otherwise the server's local time is used.
|
|
72
|
+
*/
|
|
73
|
+
function cronPartsOf(now: Date, fmt?: Intl.DateTimeFormat): CronParts {
|
|
74
|
+
if (!fmt) {
|
|
75
|
+
return {
|
|
76
|
+
minute: now.getMinutes(),
|
|
77
|
+
hour: now.getHours(),
|
|
78
|
+
dom: now.getDate(),
|
|
79
|
+
month: now.getMonth() + 1,
|
|
80
|
+
dow: now.getDay(),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const parts = fmt.formatToParts(now)
|
|
84
|
+
const get = (type: string) => parts.find(p => p.type === type)?.value ?? ''
|
|
85
|
+
const weekdays: Record<string, number> = {
|
|
86
|
+
Sun: 0,
|
|
87
|
+
Mon: 1,
|
|
88
|
+
Tue: 2,
|
|
89
|
+
Wed: 3,
|
|
90
|
+
Thu: 4,
|
|
91
|
+
Fri: 5,
|
|
92
|
+
Sat: 6,
|
|
93
|
+
}
|
|
94
|
+
let hour = parseInt(get('hour'), 10)
|
|
95
|
+
if (hour === 24) hour = 0 // some ICU builds render midnight as '24' under hour12:false
|
|
96
|
+
return {
|
|
97
|
+
minute: parseInt(get('minute'), 10),
|
|
98
|
+
hour,
|
|
99
|
+
dom: parseInt(get('day'), 10),
|
|
100
|
+
month: parseInt(get('month'), 10),
|
|
101
|
+
dow: weekdays[get('weekday')] ?? 0,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class JobsService implements NixxieJobsService {
|
|
106
|
+
private jobs = new Map<string, JobState>()
|
|
107
|
+
private config: JobsConfig
|
|
108
|
+
private cronTimer: ReturnType<typeof setTimeout> | null = null
|
|
109
|
+
private cronStarted = false
|
|
110
|
+
private lastCronMinute = -1
|
|
111
|
+
private cronFormatter?: Intl.DateTimeFormat
|
|
112
|
+
private closed = false
|
|
113
|
+
|
|
114
|
+
constructor(config: JobsConfig) {
|
|
115
|
+
this.config = config
|
|
116
|
+
|
|
117
|
+
for (const job of config.jobs ?? []) {
|
|
118
|
+
this.define(job)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Lazily-built (and reused) Intl formatter for the configured cron timezone, if any. */
|
|
123
|
+
private getCronFormatter(): Intl.DateTimeFormat | undefined {
|
|
124
|
+
if (!this.config.timezone) return undefined
|
|
125
|
+
if (!this.cronFormatter) {
|
|
126
|
+
this.cronFormatter = new Intl.DateTimeFormat('en-US', {
|
|
127
|
+
timeZone: this.config.timezone,
|
|
128
|
+
hour12: false,
|
|
129
|
+
weekday: 'short',
|
|
130
|
+
month: '2-digit',
|
|
131
|
+
day: '2-digit',
|
|
132
|
+
hour: '2-digit',
|
|
133
|
+
minute: '2-digit',
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
return this.cronFormatter
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
define(job: NixxieJobDefinition): void {
|
|
140
|
+
if (this.jobs.has(job.name)) {
|
|
141
|
+
this.remove(job.name)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const state: JobState = {
|
|
145
|
+
definition: job,
|
|
146
|
+
info: {
|
|
147
|
+
name: job.name,
|
|
148
|
+
status: job.enabled === false ? 'disabled' : 'idle',
|
|
149
|
+
runs: 0,
|
|
150
|
+
},
|
|
151
|
+
timer: null,
|
|
152
|
+
running: false,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
this.jobs.set(job.name, state)
|
|
156
|
+
|
|
157
|
+
if (job.enabled !== false) {
|
|
158
|
+
this.schedule(state)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (job.runImmediately) {
|
|
162
|
+
void this.runJob(state)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
remove(name: string): void {
|
|
167
|
+
const state = this.jobs.get(name)
|
|
168
|
+
if (!state) return
|
|
169
|
+
this.clearTimer(state)
|
|
170
|
+
this.jobs.delete(name)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async trigger(name: string): Promise<void> {
|
|
174
|
+
const state = this.jobs.get(name)
|
|
175
|
+
if (!state) throw new Error(`Job "${name}" is not registered`)
|
|
176
|
+
await this.runJob(state)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
list(): NixxieJobInfo[] {
|
|
180
|
+
return Array.from(this.jobs.values()).map(s => ({ ...s.info }))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
get(name: string): NixxieJobInfo | undefined {
|
|
184
|
+
const state = this.jobs.get(name)
|
|
185
|
+
return state ? { ...state.info } : undefined
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async close(): Promise<void> {
|
|
189
|
+
this.closed = true
|
|
190
|
+
if (this.cronTimer) {
|
|
191
|
+
clearTimeout(this.cronTimer)
|
|
192
|
+
this.cronTimer = null
|
|
193
|
+
}
|
|
194
|
+
this.cronStarted = false
|
|
195
|
+
for (const state of this.jobs.values()) {
|
|
196
|
+
this.clearTimer(state)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const timeout = this.config.shutdownTimeout ?? 5000
|
|
200
|
+
const deadline = Date.now() + timeout
|
|
201
|
+
|
|
202
|
+
// Wait for running jobs
|
|
203
|
+
while (Date.now() < deadline) {
|
|
204
|
+
const anyRunning = Array.from(this.jobs.values()).some(s => s.running)
|
|
205
|
+
if (!anyRunning) break
|
|
206
|
+
await new Promise(r => setTimeout(r, 100))
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private schedule(state: JobState): void {
|
|
211
|
+
const { definition } = state
|
|
212
|
+
const schedule = definition.schedule
|
|
213
|
+
|
|
214
|
+
if (typeof schedule === 'number') {
|
|
215
|
+
// Interval in ms
|
|
216
|
+
state.timer = setInterval(() => void this.runJob(state), schedule)
|
|
217
|
+
} else {
|
|
218
|
+
// Cron expression — a single shared per-minute ticker drives all cron jobs.
|
|
219
|
+
if (!this.cronStarted) {
|
|
220
|
+
this.startCronTick()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
state.info.nextRun = this.computeNextRun(state)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Compute the next time a job will run (exact for intervals, next matching minute for cron). */
|
|
227
|
+
private computeNextRun(state: JobState): Date | undefined {
|
|
228
|
+
const schedule = state.definition.schedule
|
|
229
|
+
if (typeof schedule === 'number') {
|
|
230
|
+
return new Date(Date.now() + schedule)
|
|
231
|
+
}
|
|
232
|
+
let matcher: (parts: CronParts) => boolean
|
|
233
|
+
try {
|
|
234
|
+
matcher = parseCron(schedule)
|
|
235
|
+
} catch {
|
|
236
|
+
return undefined
|
|
237
|
+
}
|
|
238
|
+
const fmt = this.getCronFormatter()
|
|
239
|
+
const start = new Date()
|
|
240
|
+
start.setSeconds(0, 0)
|
|
241
|
+
// Search minute-by-minute up to ~366 days ahead (covers yearly schedules). The loop exits on
|
|
242
|
+
// the first match, so frequent schedules are cheap.
|
|
243
|
+
for (let i = 1; i <= 366 * 24 * 60; i++) {
|
|
244
|
+
const candidate = new Date(start.getTime() + i * 60_000)
|
|
245
|
+
if (matcher(cronPartsOf(candidate, fmt))) return candidate
|
|
246
|
+
}
|
|
247
|
+
return undefined
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private startCronTick(): void {
|
|
251
|
+
this.cronStarted = true
|
|
252
|
+
|
|
253
|
+
const scheduleNext = () => {
|
|
254
|
+
if (this.closed) return
|
|
255
|
+
// Re-arm to the next minute boundary every tick. A fixed `setInterval(…, 60_000)` accumulates
|
|
256
|
+
// drift and can eventually skip a whole minute, silently losing a cron execution.
|
|
257
|
+
const ms = 60_000 - (Date.now() % 60_000)
|
|
258
|
+
this.cronTimer = setTimeout(tick, ms)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const tick = () => {
|
|
262
|
+
this.cronTimer = null
|
|
263
|
+
if (this.closed) return
|
|
264
|
+
const now = new Date()
|
|
265
|
+
const minuteKey = Math.floor(now.getTime() / 60_000)
|
|
266
|
+
// Only evaluate each wall-clock minute once, even if a tick fires slightly late/twice.
|
|
267
|
+
if (minuteKey !== this.lastCronMinute) {
|
|
268
|
+
this.lastCronMinute = minuteKey
|
|
269
|
+
const parts = cronPartsOf(now, this.getCronFormatter())
|
|
270
|
+
for (const state of this.jobs.values()) {
|
|
271
|
+
if (typeof state.definition.schedule !== 'string') continue
|
|
272
|
+
if (state.info.status === 'disabled' || state.running) continue
|
|
273
|
+
try {
|
|
274
|
+
const matcher = parseCron(state.definition.schedule)
|
|
275
|
+
if (matcher(parts)) void this.runJob(state)
|
|
276
|
+
} catch (err) {
|
|
277
|
+
console.error(`[nixxie/jobs] Invalid cron for "${state.definition.name}":`, err)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
scheduleNext()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
scheduleNext()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private async runJob(state: JobState): Promise<void> {
|
|
288
|
+
if (state.running || this.closed) return
|
|
289
|
+
|
|
290
|
+
state.running = true
|
|
291
|
+
state.info.status = 'running'
|
|
292
|
+
const start = Date.now()
|
|
293
|
+
const scheduledAt = new Date()
|
|
294
|
+
|
|
295
|
+
const run = Promise.resolve(state.definition.handler({ jobId: state.definition.name, scheduledAt }))
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const timeoutMs = state.definition.timeout
|
|
299
|
+
|
|
300
|
+
if (timeoutMs) {
|
|
301
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
302
|
+
try {
|
|
303
|
+
await Promise.race([
|
|
304
|
+
run,
|
|
305
|
+
new Promise<never>((_, reject) => {
|
|
306
|
+
timer = setTimeout(
|
|
307
|
+
() => reject(new Error(`Job "${state.definition.name}" timed out after ${timeoutMs}ms`)),
|
|
308
|
+
timeoutMs
|
|
309
|
+
)
|
|
310
|
+
}),
|
|
311
|
+
])
|
|
312
|
+
} finally {
|
|
313
|
+
if (timer) clearTimeout(timer)
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
await run
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
state.info.status = 'idle'
|
|
320
|
+
state.info.lastRun = new Date()
|
|
321
|
+
state.info.runs++
|
|
322
|
+
state.info.lastError = undefined
|
|
323
|
+
|
|
324
|
+
this.config.onComplete?.(state.definition.name, Date.now() - start)
|
|
325
|
+
} catch (err) {
|
|
326
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
327
|
+
state.info.status = 'error'
|
|
328
|
+
state.info.lastRun = new Date()
|
|
329
|
+
state.info.lastError = error.message
|
|
330
|
+
|
|
331
|
+
this.config.onError?.(state.definition.name, error)
|
|
332
|
+
console.error(`[nixxie/jobs] Job "${state.definition.name}" failed:`, error)
|
|
333
|
+
} finally {
|
|
334
|
+
// Release the overlap lock only once the handler actually settles. On timeout we stopped
|
|
335
|
+
// *waiting* for it, but it may still be running in the background — releasing synchronously
|
|
336
|
+
// would let the next tick start a second concurrent run. (Normally `run` is already settled.)
|
|
337
|
+
void run
|
|
338
|
+
.catch(() => {})
|
|
339
|
+
.then(() => {
|
|
340
|
+
state.running = false
|
|
341
|
+
if (!this.closed) state.info.nextRun = this.computeNextRun(state)
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private clearTimer(state: JobState): void {
|
|
347
|
+
if (state.timer) {
|
|
348
|
+
clearInterval(state.timer as ReturnType<typeof setInterval>)
|
|
349
|
+
state.timer = null
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { JobsConfig } from './types'
|
|
2
|
+
import { JobsService } from './JobsService'
|
|
3
|
+
|
|
4
|
+
export function createJobs(config: JobsConfig = {}): JobsService {
|
|
5
|
+
return new JobsService(config)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export { JobsService }
|
|
9
|
+
export type { JobsConfig } from './types'
|
|
10
|
+
export type { NixxieJobDefinition, NixxieJobInfo, NixxieJobsService } from '@nixxie-cms/core'
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { NixxieJobDefinition, NixxieJobInfo, NixxieJobsService } from '@nixxie-cms/core'
|
|
2
|
+
|
|
3
|
+
export type { NixxieJobDefinition, NixxieJobInfo, NixxieJobsService }
|
|
4
|
+
|
|
5
|
+
export type JobsConfig = {
|
|
6
|
+
/**
|
|
7
|
+
* Job definitions to register on start.
|
|
8
|
+
* Can also be registered later via `service.define()`.
|
|
9
|
+
*/
|
|
10
|
+
jobs?: NixxieJobDefinition[]
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Timezone to use for cron expression evaluation.
|
|
14
|
+
* Defaults to the system timezone.
|
|
15
|
+
*/
|
|
16
|
+
timezone?: string
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How long (ms) to wait for in-flight jobs to finish on close().
|
|
20
|
+
* @default 5000
|
|
21
|
+
*/
|
|
22
|
+
shutdownTimeout?: number
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Called when a job throws an error.
|
|
26
|
+
*/
|
|
27
|
+
onError?: (name: string, error: Error) => void
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Called after each successful job execution.
|
|
31
|
+
*/
|
|
32
|
+
onComplete?: (name: string, duration: number) => void
|
|
33
|
+
}
|