@alt-javascript/camel-lite-component-cron 1.0.2
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 +62 -0
- package/package.json +43 -0
- package/src/CronComponent.js +11 -0
- package/src/CronConsumer.js +59 -0
- package/src/CronEndpoint.js +46 -0
- package/src/index.js +3 -0
- package/test/cron.test.js +111 -0
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[](https://opensource.org/licenses/MIT)
|
|
2
|
+
|
|
3
|
+
## What
|
|
4
|
+
|
|
5
|
+
Cron-scheduled exchange trigger via [`node-cron`](https://www.npmjs.com/package/node-cron). Fires at the times defined by a standard 5- or 6-field cron expression. The schedule is validated at endpoint construction — an invalid expression throws a `CamelError` before the context starts.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install camel-lite-component-cron
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## URI Syntax
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
cron:name?schedule=<cron-expression>[&timezone=UTC]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
URL-encode spaces in the cron expression as `+`.
|
|
20
|
+
|
|
21
|
+
| Parameter | Default | Description |
|
|
22
|
+
|------------|---------|-------------|
|
|
23
|
+
| `schedule` | *(required)* | 5-field (`* * * * *`) or 6-field (`* * * * * *`) cron expression with spaces encoded as `+`. Validated by `node-cron` at construction. |
|
|
24
|
+
| `timezone` | `UTC` | IANA timezone name (e.g. `America/New_York`). |
|
|
25
|
+
|
|
26
|
+
### Headers Set on Each Exchange
|
|
27
|
+
|
|
28
|
+
| Header | Type | Description |
|
|
29
|
+
|----------------------|----------|-------------|
|
|
30
|
+
| `CamelCronName` | `string` | The cron name from the URI. |
|
|
31
|
+
| `CamelCronFiredTime` | `Date` | Timestamp of the scheduled firing. |
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
import { CamelContext } from 'camel-lite-core';
|
|
37
|
+
import { CronComponent } from 'camel-lite-component-cron';
|
|
38
|
+
import { DirectComponent } from 'camel-lite-component-direct';
|
|
39
|
+
|
|
40
|
+
const context = new CamelContext();
|
|
41
|
+
context.addComponent('cron', new CronComponent());
|
|
42
|
+
context.addComponent('direct', new DirectComponent());
|
|
43
|
+
|
|
44
|
+
context.addRoutes({
|
|
45
|
+
configure(ctx) {
|
|
46
|
+
// Fires at midnight UTC every day
|
|
47
|
+
ctx.from('cron:midnight?schedule=0+0+0+*+*+*')
|
|
48
|
+
.to('direct:dailyJob');
|
|
49
|
+
|
|
50
|
+
ctx.from('direct:dailyJob')
|
|
51
|
+
.process(exchange => {
|
|
52
|
+
console.log('Daily job triggered at', exchange.in.getHeader('CamelCronFiredTime'));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await context.start();
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## See Also
|
|
61
|
+
|
|
62
|
+
[camel-lite — root README](../../README.md)
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alt-javascript/camel-lite-component-cron",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.js"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@alt-javascript/common": "^3.0.7",
|
|
10
|
+
"@alt-javascript/config": "^3.0.7",
|
|
11
|
+
"@alt-javascript/logger": "^3.0.7",
|
|
12
|
+
"node-cron": "^4.0.0",
|
|
13
|
+
"@alt-javascript/camel-lite-core": "1.0.2"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/alt-javascript/camel-lite"
|
|
21
|
+
},
|
|
22
|
+
"author": "Craig Parravicini",
|
|
23
|
+
"contributors": [
|
|
24
|
+
"Claude (Anthropic)",
|
|
25
|
+
"Apache Camel — design inspiration and pattern source"
|
|
26
|
+
],
|
|
27
|
+
"keywords": [
|
|
28
|
+
"alt-javascript",
|
|
29
|
+
"camel",
|
|
30
|
+
"camel-lite",
|
|
31
|
+
"eai",
|
|
32
|
+
"eip",
|
|
33
|
+
"integration",
|
|
34
|
+
"cron",
|
|
35
|
+
"scheduler",
|
|
36
|
+
"timer",
|
|
37
|
+
"component"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"registry": "https://registry.npmjs.org/",
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Component } from '@alt-javascript/camel-lite-core';
|
|
2
|
+
import CronEndpoint from './CronEndpoint.js';
|
|
3
|
+
|
|
4
|
+
class CronComponent extends Component {
|
|
5
|
+
createEndpoint(uri, remaining, parameters, context) {
|
|
6
|
+
return new CronEndpoint(uri, remaining, parameters, context);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export { CronComponent };
|
|
11
|
+
export default CronComponent;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Consumer, Exchange } from '@alt-javascript/camel-lite-core';
|
|
2
|
+
import { schedule as cronSchedule } from 'node-cron';
|
|
3
|
+
import { LoggerFactory } from '@alt-javascript/logger';
|
|
4
|
+
|
|
5
|
+
const log = LoggerFactory.getLogger('@alt-javascript/camel-lite/CronConsumer');
|
|
6
|
+
|
|
7
|
+
class CronConsumer extends Consumer {
|
|
8
|
+
#uri;
|
|
9
|
+
#name;
|
|
10
|
+
#schedule;
|
|
11
|
+
#timezone;
|
|
12
|
+
#context;
|
|
13
|
+
#pipeline;
|
|
14
|
+
#task = null;
|
|
15
|
+
|
|
16
|
+
constructor(uri, name, schedule, timezone, context, pipeline) {
|
|
17
|
+
super();
|
|
18
|
+
this.#uri = uri;
|
|
19
|
+
this.#name = name;
|
|
20
|
+
this.#schedule = schedule;
|
|
21
|
+
this.#timezone = timezone;
|
|
22
|
+
this.#context = context;
|
|
23
|
+
this.#pipeline = pipeline;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get uri() { return this.#uri; }
|
|
27
|
+
|
|
28
|
+
async start() {
|
|
29
|
+
this.#context.registerConsumer(this.#uri, this);
|
|
30
|
+
log.info(`Cron consumer started: ${this.#uri} schedule='${this.#schedule}' tz='${this.#timezone}'`);
|
|
31
|
+
|
|
32
|
+
this.#task = cronSchedule(this.#schedule, async () => {
|
|
33
|
+
const exchange = new Exchange();
|
|
34
|
+
exchange.in.setHeader('CamelCronName', this.#name);
|
|
35
|
+
exchange.in.setHeader('CamelCronFiredTime', new Date());
|
|
36
|
+
exchange.in.body = null;
|
|
37
|
+
|
|
38
|
+
log.debug(`Cron ${this.#name} fired`);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await this.#pipeline.run(exchange);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
log.error(`Cron ${this.#name} error: ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
}, { timezone: this.#timezone });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async stop() {
|
|
49
|
+
if (this.#task) {
|
|
50
|
+
this.#task.stop();
|
|
51
|
+
this.#task = null;
|
|
52
|
+
}
|
|
53
|
+
this.#context.registerConsumer(this.#uri, null);
|
|
54
|
+
log.info(`Cron consumer stopped: ${this.#uri}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { CronConsumer };
|
|
59
|
+
export default CronConsumer;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Endpoint, CamelError } from '@alt-javascript/camel-lite-core';
|
|
2
|
+
import { validate } from 'node-cron';
|
|
3
|
+
import CronConsumer from './CronConsumer.js';
|
|
4
|
+
|
|
5
|
+
class CronEndpoint extends Endpoint {
|
|
6
|
+
#uri;
|
|
7
|
+
#name;
|
|
8
|
+
#schedule;
|
|
9
|
+
#timezone;
|
|
10
|
+
#context;
|
|
11
|
+
|
|
12
|
+
constructor(uri, remaining, parameters, context) {
|
|
13
|
+
super();
|
|
14
|
+
this.#uri = uri;
|
|
15
|
+
this.#name = remaining || 'cron';
|
|
16
|
+
this.#context = context;
|
|
17
|
+
|
|
18
|
+
const params = parameters instanceof URLSearchParams
|
|
19
|
+
? parameters
|
|
20
|
+
: new URLSearchParams(typeof parameters === 'string' ? parameters : '');
|
|
21
|
+
|
|
22
|
+
const schedule = params.get('schedule');
|
|
23
|
+
if (!schedule) {
|
|
24
|
+
throw new CamelError(`cron: URI missing required 'schedule' parameter: ${uri}`);
|
|
25
|
+
}
|
|
26
|
+
// node-cron uses + as space in URI query — decode it
|
|
27
|
+
const decoded = decodeURIComponent(schedule.replace(/\+/g, ' '));
|
|
28
|
+
if (!validate(decoded)) {
|
|
29
|
+
throw new CamelError(`cron: invalid cron expression '${decoded}' in URI: ${uri}`);
|
|
30
|
+
}
|
|
31
|
+
this.#schedule = decoded;
|
|
32
|
+
this.#timezone = params.get('timezone') ?? 'UTC';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get uri() { return this.#uri; }
|
|
36
|
+
get name() { return this.#name; }
|
|
37
|
+
get schedule() { return this.#schedule; }
|
|
38
|
+
get timezone() { return this.#timezone; }
|
|
39
|
+
|
|
40
|
+
createConsumer(pipeline) {
|
|
41
|
+
return new CronConsumer(this.#uri, this.#name, this.#schedule, this.#timezone, this.#context, pipeline);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { CronEndpoint };
|
|
46
|
+
export default CronEndpoint;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { CamelContext, CamelError } from '@alt-javascript/camel-lite-core';
|
|
4
|
+
import { CronComponent, CronEndpoint } from '../src/index.js';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Unit: endpoint parameter parsing
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
describe('CronEndpoint: parameter parsing', () => {
|
|
11
|
+
function makeEndpoint(query) {
|
|
12
|
+
const params = new URLSearchParams(query);
|
|
13
|
+
// remaining = 'job', uri fabricated
|
|
14
|
+
return new CronEndpoint('cron:job?' + query, 'job', params, null);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
it('parses a valid 5-field schedule', () => {
|
|
18
|
+
const ep = makeEndpoint('schedule=* * * * *');
|
|
19
|
+
assert.equal(ep.schedule, '* * * * *');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('parses a valid 6-field schedule', () => {
|
|
23
|
+
const ep = makeEndpoint('schedule=* * * * * *');
|
|
24
|
+
assert.equal(ep.schedule, '* * * * * *');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('decodes + as space in schedule (URL encoding)', () => {
|
|
28
|
+
const ep = makeEndpoint('schedule=*+*+*+*+*+*');
|
|
29
|
+
assert.equal(ep.schedule, '* * * * * *');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('defaults timezone to UTC', () => {
|
|
33
|
+
const ep = makeEndpoint('schedule=* * * * *');
|
|
34
|
+
assert.equal(ep.timezone, 'UTC');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('parses custom timezone', () => {
|
|
38
|
+
const ep = makeEndpoint('schedule=* * * * *&timezone=America/New_York');
|
|
39
|
+
assert.equal(ep.timezone, 'America/New_York');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('name comes from remaining path segment', () => {
|
|
43
|
+
const params = new URLSearchParams('schedule=* * * * *');
|
|
44
|
+
const ep = new CronEndpoint('cron:myJob?schedule=* * * * *', 'myJob', params, null);
|
|
45
|
+
assert.equal(ep.name, 'myJob');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('throws CamelError when schedule is missing', () => {
|
|
49
|
+
assert.throws(() => makeEndpoint(''), /missing required.*schedule/i);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('throws CamelError for an invalid cron expression', () => {
|
|
53
|
+
assert.throws(() => makeEndpoint('schedule=not-valid'), /invalid cron expression/i);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Integration: fires exchanges on a fast schedule
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
describe('CronConsumer: fires exchanges', () => {
|
|
62
|
+
it('fires on every-second schedule and headers are set', async () => {
|
|
63
|
+
const ctx = new CamelContext();
|
|
64
|
+
ctx.addComponent('cron', new CronComponent());
|
|
65
|
+
|
|
66
|
+
const fired = [];
|
|
67
|
+
const { RouteBuilder } = await import('@alt-javascript/camel-lite-core');
|
|
68
|
+
const builder = new RouteBuilder();
|
|
69
|
+
// every-second cron (6-field with seconds support)
|
|
70
|
+
builder.from('cron:job?schedule=* * * * * *').process(ex => {
|
|
71
|
+
fired.push({
|
|
72
|
+
name: ex.in.getHeader('CamelCronName'),
|
|
73
|
+
time: ex.in.getHeader('CamelCronFiredTime'),
|
|
74
|
+
body: ex.in.body,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
ctx.addRoutes(builder);
|
|
78
|
+
await ctx.start();
|
|
79
|
+
|
|
80
|
+
// Wait 2.5 seconds — should see at least 2 fires
|
|
81
|
+
await new Promise(r => setTimeout(r, 2500));
|
|
82
|
+
await ctx.stop();
|
|
83
|
+
|
|
84
|
+
assert.ok(fired.length >= 2, `expected >= 2 fires, got ${fired.length}`);
|
|
85
|
+
assert.equal(fired[0].name, 'job');
|
|
86
|
+
assert.ok(fired[0].time instanceof Date);
|
|
87
|
+
assert.equal(fired[0].body, null);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('stop() prevents further fires', async () => {
|
|
91
|
+
const ctx = new CamelContext();
|
|
92
|
+
ctx.addComponent('cron', new CronComponent());
|
|
93
|
+
|
|
94
|
+
let fired = 0;
|
|
95
|
+
const { RouteBuilder } = await import('@alt-javascript/camel-lite-core');
|
|
96
|
+
const builder = new RouteBuilder();
|
|
97
|
+
builder.from('cron:stopper?schedule=* * * * * *').process(() => { fired++; });
|
|
98
|
+
ctx.addRoutes(builder);
|
|
99
|
+
await ctx.start();
|
|
100
|
+
|
|
101
|
+
// Let it fire at least once
|
|
102
|
+
await new Promise(r => setTimeout(r, 1200));
|
|
103
|
+
await ctx.stop();
|
|
104
|
+
const countAtStop = fired;
|
|
105
|
+
|
|
106
|
+
// Wait another second — should not fire more
|
|
107
|
+
await new Promise(r => setTimeout(r, 1200));
|
|
108
|
+
assert.equal(fired, countAtStop, 'cron should not fire after stop');
|
|
109
|
+
assert.ok(fired >= 1, 'should have fired at least once');
|
|
110
|
+
});
|
|
111
|
+
});
|