@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 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
+ }