@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alt-javascript/camel-lite-core",
3
- "version": "1.0.2",
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.0.2",
13
- "@alt-javascript/camel-lite-component-seda": "1.0.2"
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",
@@ -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
- const consumer = endpoint.createConsumer(compiledPipeline);
102
- this.#consumers.set(fromUri, consumer);
103
- await consumer.start();
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');
@@ -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 queue-based
9
- * endpoints (currently seda:) registered in a running CamelContext.
6
+ * ConsumerTemplate — high-level API for polling messages from endpoints
7
+ * registered in a running CamelContext.
10
8
  *
11
- * Only polling-capable endpoints are supported. Push-model endpoints like
12
- * direct: do not expose a dequeuable queue and will throw a clear error.
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
- const scheme = this.#scheme(uri);
36
- if (!SUPPORTED_POLL_SCHEMES.has(scheme)) {
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 ctx.start();
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 from 'direct:'/
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
+ });