@alt-javascript/camel-lite-core 1.0.2 → 1.1.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/package.json +4 -5
- package/src/CamelContext.js +32 -3
- package/src/ConsumerTemplate.js +14 -13
- package/src/PollingConsumerAdapter.js +142 -0
- package/src/index.js +1 -0
- package/test/ConsumerTemplate.test.js +76 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alt-javascript/camel-lite-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.js"
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"test": "node --test"
|
|
10
10
|
},
|
|
11
11
|
"devDependencies": {
|
|
12
|
-
"@alt-javascript/camel-lite-component-direct": "1.
|
|
13
|
-
"@alt-javascript/camel-lite-component-seda": "1.
|
|
12
|
+
"@alt-javascript/camel-lite-component-direct": "1.1.1",
|
|
13
|
+
"@alt-javascript/camel-lite-component-seda": "1.1.1"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@alt-javascript/common": "^3.0.7",
|
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"author": "Craig Parravicini",
|
|
26
26
|
"contributors": [
|
|
27
|
-
"Claude (Anthropic)"
|
|
28
|
-
"Apache Camel — design inspiration and pattern source"
|
|
27
|
+
"Claude (Anthropic)"
|
|
29
28
|
],
|
|
30
29
|
"keywords": [
|
|
31
30
|
"alt-javascript",
|
package/src/CamelContext.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LoggerFactory } from '@alt-javascript/logger';
|
|
2
|
+
import { PollingConsumerAdapter } from './PollingConsumerAdapter.js';
|
|
2
3
|
|
|
3
4
|
const log = LoggerFactory.getLogger('@alt-javascript/camel-lite/CamelContext');
|
|
4
5
|
|
|
@@ -10,6 +11,7 @@ class CamelContext {
|
|
|
10
11
|
#beans = new Map();
|
|
11
12
|
#started = false;
|
|
12
13
|
#abortController = null;
|
|
14
|
+
#pollingUris = new Set();
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Register a named bean in the context.
|
|
@@ -41,6 +43,15 @@ class CamelContext {
|
|
|
41
43
|
return Array.from(this.#beans.entries());
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Declare which consumer URIs should be wrapped with a PollingConsumerAdapter
|
|
48
|
+
* so they can be polled via ConsumerTemplate. Must be set before start().
|
|
49
|
+
* @param {Set<string>|Iterable<string>} uriSet
|
|
50
|
+
*/
|
|
51
|
+
set pollingUris(uriSet) {
|
|
52
|
+
this.#pollingUris = uriSet instanceof Set ? uriSet : new Set(uriSet);
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
addComponent(scheme, component) {
|
|
45
56
|
this.#components.set(scheme, component);
|
|
46
57
|
return this;
|
|
@@ -98,9 +109,27 @@ class CamelContext {
|
|
|
98
109
|
|
|
99
110
|
const compiledPipeline = routeDef.compile(this, { signal });
|
|
100
111
|
const endpoint = component.createEndpoint(fromUri, remaining, params, this);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
112
|
+
|
|
113
|
+
let consumerToRegister;
|
|
114
|
+
if (this.#pollingUris.has(fromUri)) {
|
|
115
|
+
// Wrap the real consumer with a PollingConsumerAdapter so ConsumerTemplate
|
|
116
|
+
// can poll it via the BufferQueue.
|
|
117
|
+
const adapter = new PollingConsumerAdapter();
|
|
118
|
+
const capPipeline = adapter.capturedPipeline;
|
|
119
|
+
const realConsumer = endpoint.createConsumer(capPipeline);
|
|
120
|
+
adapter.setConsumer(realConsumer);
|
|
121
|
+
consumerToRegister = adapter;
|
|
122
|
+
} else {
|
|
123
|
+
consumerToRegister = endpoint.createConsumer(compiledPipeline);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this.#consumers.set(fromUri, consumerToRegister);
|
|
127
|
+
await consumerToRegister.start();
|
|
128
|
+
// For polling URIs, the real consumer's start() calls registerConsumer(uri, self),
|
|
129
|
+
// which overwrites the adapter. Re-register the adapter so getConsumer() returns it.
|
|
130
|
+
if (this.#pollingUris.has(fromUri)) {
|
|
131
|
+
this.#consumers.set(fromUri, consumerToRegister);
|
|
132
|
+
}
|
|
104
133
|
}
|
|
105
134
|
|
|
106
135
|
log.info('Apache Camel Lite started');
|
package/src/ConsumerTemplate.js
CHANGED
|
@@ -2,14 +2,14 @@ import { LoggerFactory } from '@alt-javascript/logger';
|
|
|
2
2
|
|
|
3
3
|
const log = LoggerFactory.getLogger('@alt-javascript/camel-lite/ConsumerTemplate');
|
|
4
4
|
|
|
5
|
-
const SUPPORTED_POLL_SCHEMES = new Set(['seda']);
|
|
6
|
-
|
|
7
5
|
/**
|
|
8
|
-
* ConsumerTemplate — high-level API for polling messages from
|
|
9
|
-
*
|
|
6
|
+
* ConsumerTemplate — high-level API for polling messages from endpoints
|
|
7
|
+
* registered in a running CamelContext.
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Any consumer that exposes a `poll(timeoutMs)` method is supported.
|
|
10
|
+
* For push-model endpoints (direct:, timer:, etc.) you must declare the URI
|
|
11
|
+
* in `ctx.pollingUris` before starting the context so CamelContext wraps it
|
|
12
|
+
* with a PollingConsumerAdapter.
|
|
13
13
|
*
|
|
14
14
|
* Usage:
|
|
15
15
|
* const ct = new ConsumerTemplate(context);
|
|
@@ -32,13 +32,8 @@ class ConsumerTemplate {
|
|
|
32
32
|
* @returns {Promise<Exchange|null>}
|
|
33
33
|
*/
|
|
34
34
|
async receive(uri, timeoutMs = 5000) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
throw new Error(
|
|
38
|
-
`ConsumerTemplate does not support polling from '${scheme}:'. ` +
|
|
39
|
-
`Supported schemes: ${[...SUPPORTED_POLL_SCHEMES].join(', ')}`
|
|
40
|
-
);
|
|
41
|
-
}
|
|
35
|
+
// Validate URI format
|
|
36
|
+
this.#scheme(uri);
|
|
42
37
|
|
|
43
38
|
log.info(`ConsumerTemplate polling from ${uri}`);
|
|
44
39
|
|
|
@@ -47,6 +42,12 @@ class ConsumerTemplate {
|
|
|
47
42
|
throw new Error(`ConsumerTemplate: no consumer registered for '${uri}' — is the context started?`);
|
|
48
43
|
}
|
|
49
44
|
|
|
45
|
+
if (typeof consumer.poll !== 'function') {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`ConsumerTemplate: consumer for '${uri}' does not support polling — wrap it with PollingConsumerAdapter`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
50
51
|
return consumer.poll(timeoutMs);
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { LoggerFactory } from '@alt-javascript/logger';
|
|
2
|
+
|
|
3
|
+
const log = LoggerFactory.getLogger('@alt-javascript/camel-lite/PollingConsumerAdapter');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Inline async queue — same mechanics as SedaQueue without the SedaQueueFullError
|
|
7
|
+
* dependency. Used internally by PollingConsumerAdapter.
|
|
8
|
+
*/
|
|
9
|
+
class BufferQueue {
|
|
10
|
+
#items = [];
|
|
11
|
+
#waiters = [];
|
|
12
|
+
#closed = false;
|
|
13
|
+
|
|
14
|
+
enqueue(item) {
|
|
15
|
+
if (this.#closed) return; // silently drop after close
|
|
16
|
+
if (this.#waiters.length > 0) {
|
|
17
|
+
this.#waiters.shift().resolve(item);
|
|
18
|
+
} else {
|
|
19
|
+
this.#items.push(item);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
dequeue() {
|
|
24
|
+
if (this.#items.length > 0) {
|
|
25
|
+
return Promise.resolve(this.#items.shift());
|
|
26
|
+
}
|
|
27
|
+
if (this.#closed) {
|
|
28
|
+
return Promise.resolve(null);
|
|
29
|
+
}
|
|
30
|
+
return new Promise(resolve => this.#waiters.push({ resolve }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
close() {
|
|
34
|
+
this.#closed = true;
|
|
35
|
+
for (const waiter of this.#waiters) {
|
|
36
|
+
waiter.resolve(null);
|
|
37
|
+
}
|
|
38
|
+
this.#waiters = [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get closed() {
|
|
42
|
+
return this.#closed;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* PollingConsumerAdapter — wraps any push-model Consumer (timer:, direct:, etc.)
|
|
48
|
+
* and exposes a `poll(timeoutMs)` method compatible with ConsumerTemplate.
|
|
49
|
+
*
|
|
50
|
+
* The adapter injects a "capture pipeline" into the real consumer at construction
|
|
51
|
+
* time. Whenever the real consumer fires an exchange into that pipeline, the
|
|
52
|
+
* adapter enqueues it in an internal BufferQueue. Callers can then drain the
|
|
53
|
+
* queue via `poll()`.
|
|
54
|
+
*
|
|
55
|
+
* Usage (handled automatically by CamelContext when pollingUris is set):
|
|
56
|
+
*
|
|
57
|
+
* const adapter = new PollingConsumerAdapter();
|
|
58
|
+
* const realConsumer = endpoint.createConsumer(adapter.capturedPipeline);
|
|
59
|
+
* adapter.setConsumer(realConsumer);
|
|
60
|
+
* await adapter.start();
|
|
61
|
+
* const exchange = await adapter.poll(5000);
|
|
62
|
+
* await adapter.stop();
|
|
63
|
+
*/
|
|
64
|
+
class PollingConsumerAdapter {
|
|
65
|
+
#consumer = null;
|
|
66
|
+
#queue = new BufferQueue();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The fake pipeline that the real consumer fires exchanges into.
|
|
70
|
+
* Returns a plain object with a `run(exchange)` method so it satisfies
|
|
71
|
+
* the Pipeline interface expected by all consumers.
|
|
72
|
+
*/
|
|
73
|
+
get capturedPipeline() {
|
|
74
|
+
return {
|
|
75
|
+
run: (exchange) => {
|
|
76
|
+
log.debug(`PollingConsumerAdapter captured exchange ${exchange?.in?.messageId}`);
|
|
77
|
+
this.#queue.enqueue(exchange);
|
|
78
|
+
return Promise.resolve(exchange);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Inject the real consumer after creation (two-phase init).
|
|
85
|
+
* @param {Consumer} consumer
|
|
86
|
+
*/
|
|
87
|
+
setConsumer(consumer) {
|
|
88
|
+
this.#consumer = consumer;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Called by DirectProducer when it looks up the consumer by URI and calls
|
|
93
|
+
* process() on it directly. Routes the exchange through the capture queue
|
|
94
|
+
* so ConsumerTemplate can drain it.
|
|
95
|
+
* @param {Exchange} exchange
|
|
96
|
+
* @returns {Promise<Exchange>}
|
|
97
|
+
*/
|
|
98
|
+
async process(exchange) {
|
|
99
|
+
log.debug(`PollingConsumerAdapter.process() captured exchange ${exchange?.in?.messageId}`);
|
|
100
|
+
this.#queue.enqueue(exchange);
|
|
101
|
+
return exchange;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Start the real consumer.
|
|
106
|
+
*/
|
|
107
|
+
async start() {
|
|
108
|
+
if (this.#consumer) {
|
|
109
|
+
await this.#consumer.start();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Stop the real consumer and close the buffer queue.
|
|
115
|
+
*/
|
|
116
|
+
async stop() {
|
|
117
|
+
if (this.#consumer) {
|
|
118
|
+
await this.#consumer.stop();
|
|
119
|
+
}
|
|
120
|
+
this.#queue.close();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Poll for the next exchange, waiting at most timeoutMs milliseconds.
|
|
125
|
+
* Returns the Exchange or null on timeout.
|
|
126
|
+
* @param {number} [timeoutMs=5000]
|
|
127
|
+
* @returns {Promise<Exchange|null>}
|
|
128
|
+
*/
|
|
129
|
+
async poll(timeoutMs = 5000) {
|
|
130
|
+
let timer;
|
|
131
|
+
const timeout = new Promise(resolve => {
|
|
132
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
133
|
+
});
|
|
134
|
+
log.debug(`PollingConsumerAdapter polling (timeout=${timeoutMs}ms)`);
|
|
135
|
+
const item = await Promise.race([this.#queue.dequeue(), timeout]);
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
return item;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export { PollingConsumerAdapter };
|
|
142
|
+
export default PollingConsumerAdapter;
|
package/src/index.js
CHANGED
|
@@ -15,3 +15,4 @@ export { AggregationStrategies } from './AggregationStrategies.js';
|
|
|
15
15
|
export { RouteLoader } from './RouteLoader.js';
|
|
16
16
|
export { ProducerTemplate } from './ProducerTemplate.js';
|
|
17
17
|
export { ConsumerTemplate } from './ConsumerTemplate.js';
|
|
18
|
+
export { PollingConsumerAdapter } from './PollingConsumerAdapter.js';
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { describe, it, before, after } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { CamelContext, ConsumerTemplate, ProducerTemplate } from '../src/index.js';
|
|
3
|
+
import { CamelContext, ConsumerTemplate, ProducerTemplate, PollingConsumerAdapter } from '../src/index.js';
|
|
4
4
|
import { DirectComponent } from '@alt-javascript/camel-lite-component-direct';
|
|
5
5
|
import { SedaComponent } from '@alt-javascript/camel-lite-component-seda';
|
|
6
|
+
import { TimerComponent } from '@alt-javascript/camel-lite-component-timer';
|
|
6
7
|
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
// Helpers
|
|
@@ -26,13 +27,18 @@ describe('ConsumerTemplate: constructor', () => {
|
|
|
26
27
|
});
|
|
27
28
|
|
|
28
29
|
describe('ConsumerTemplate: unsupported schemes', () => {
|
|
29
|
-
it('throws for direct: scheme', async () => {
|
|
30
|
+
it('throws for direct: scheme without pollingUris wrapper', async () => {
|
|
31
|
+
// direct: consumer has no poll() — expect "does not support polling" error
|
|
30
32
|
const ctx = makeContext();
|
|
31
|
-
await
|
|
33
|
+
const { RouteBuilder } = await import('../src/RouteBuilder.js');
|
|
34
|
+
const builder = new RouteBuilder();
|
|
35
|
+
builder.from('direct:foo').process(ex => ex);
|
|
36
|
+
ctx.addRoutes(builder);
|
|
37
|
+
await ctx.start(); // no pollingUris set → raw DirectConsumer registered
|
|
32
38
|
const ct = new ConsumerTemplate(ctx);
|
|
33
39
|
await assert.rejects(
|
|
34
40
|
() => ct.receive('direct:foo'),
|
|
35
|
-
/does not support polling
|
|
41
|
+
/does not support polling/
|
|
36
42
|
);
|
|
37
43
|
await ctx.stop();
|
|
38
44
|
});
|
|
@@ -144,3 +150,69 @@ describe('ConsumerTemplate: poll without competing route worker', () => {
|
|
|
144
150
|
assert.equal(exchange, null);
|
|
145
151
|
});
|
|
146
152
|
});
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// PollingConsumerAdapter: wrapping a timer: consumer
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe('ConsumerTemplate: PollingConsumerAdapter with timer:', () => {
|
|
159
|
+
let ctx;
|
|
160
|
+
|
|
161
|
+
before(async () => {
|
|
162
|
+
ctx = new CamelContext();
|
|
163
|
+
ctx.addComponent('direct', new DirectComponent());
|
|
164
|
+
ctx.addComponent('seda', new SedaComponent());
|
|
165
|
+
ctx.addComponent('timer', new TimerComponent());
|
|
166
|
+
|
|
167
|
+
const { RouteBuilder } = await import('../src/RouteBuilder.js');
|
|
168
|
+
const builder = new RouteBuilder();
|
|
169
|
+
// A timer route that fires 3 times with 50 ms period.
|
|
170
|
+
builder.from('timer:tick?period=50&repeatCount=3').process(ex => ex);
|
|
171
|
+
ctx.addRoutes(builder);
|
|
172
|
+
|
|
173
|
+
// Declare the timer URI as a polling URI before start.
|
|
174
|
+
ctx.pollingUris = new Set(['timer:tick?period=50&repeatCount=3']);
|
|
175
|
+
await ctx.start();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
after(async () => {
|
|
179
|
+
await ctx.stop();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('poll() returns a non-null Exchange with CamelTimerName header', async () => {
|
|
183
|
+
const ct = new ConsumerTemplate(ctx);
|
|
184
|
+
const exchange = await ct.receive('timer:tick?period=50&repeatCount=3', 500);
|
|
185
|
+
assert.notEqual(exchange, null, 'expected an Exchange, got null (timeout)');
|
|
186
|
+
assert.equal(
|
|
187
|
+
exchange.in.getHeader('CamelTimerName'),
|
|
188
|
+
'tick',
|
|
189
|
+
'expected CamelTimerName header to be "tick"'
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('PollingConsumerAdapter is exported from camel-lite-core index', () => {
|
|
194
|
+
assert.ok(typeof PollingConsumerAdapter === 'function', 'PollingConsumerAdapter should be a class/function');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// PollingConsumerAdapter: direct without pollingUris throws
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
describe('ConsumerTemplate: raw direct: consumer throws does not support polling', () => {
|
|
203
|
+
it('direct: consumer without pollingUris wrapper throws the expected error', async () => {
|
|
204
|
+
const ctx = makeContext();
|
|
205
|
+
const { RouteBuilder } = await import('../src/RouteBuilder.js');
|
|
206
|
+
const builder = new RouteBuilder();
|
|
207
|
+
builder.from('direct:bar').process(ex => ex);
|
|
208
|
+
ctx.addRoutes(builder);
|
|
209
|
+
// No pollingUris — DirectConsumer has no poll() method
|
|
210
|
+
await ctx.start();
|
|
211
|
+
const ct = new ConsumerTemplate(ctx);
|
|
212
|
+
await assert.rejects(
|
|
213
|
+
() => ct.receive('direct:bar', 100),
|
|
214
|
+
/does not support polling/
|
|
215
|
+
);
|
|
216
|
+
await ctx.stop();
|
|
217
|
+
});
|
|
218
|
+
});
|