@alt-javascript/camel-lite-component-seda 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 ADDED
@@ -0,0 +1,63 @@
1
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
2
+
3
+ ## What
4
+
5
+ Async in-process queuing via a blocking queue. `seda:` (Staged Event-Driven Architecture) decouples producer and consumer threads — the producer returns immediately after enqueuing, and the consumer processes independently.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install camel-lite-component-seda
11
+ ```
12
+
13
+ ## URI Syntax
14
+
15
+ ```
16
+ seda:name[?size=0&concurrentConsumers=1]
17
+ ```
18
+
19
+ | Parameter | Default | Description |
20
+ |----------------------|---------|-------------|
21
+ | `size` | `0` | Maximum queue depth. `0` = unlimited. |
22
+ | `concurrentConsumers`| `1` | Number of concurrent consumer workers draining the queue. |
23
+
24
+ ## Usage
25
+
26
+ ```js
27
+ import { CamelContext } from 'camel-lite-core';
28
+ import { SedaComponent } from 'camel-lite-component-seda';
29
+
30
+ const context = new CamelContext();
31
+ context.addComponent('seda', new SedaComponent());
32
+
33
+ context.addRoutes({
34
+ configure(ctx) {
35
+ // Consumer route — runs async
36
+ ctx.from('seda:work')
37
+ .process(exchange => {
38
+ console.log('Processing:', exchange.in.body);
39
+ });
40
+
41
+ // Producer route — fire-and-forget
42
+ ctx.from('direct:submit')
43
+ .to('seda:work');
44
+ }
45
+ });
46
+
47
+ await context.start();
48
+
49
+ // Fire-and-forget: returns before seda:work processes the exchange
50
+ const template = context.createProducerTemplate();
51
+ await template.sendBody('seda:work', 'task payload');
52
+
53
+ // Receive a single body (blocks until one is available)
54
+ const consumer = context.createConsumerTemplate();
55
+ const body = await consumer.receiveBody('seda:work');
56
+ console.log(body);
57
+
58
+ await context.stop();
59
+ ```
60
+
61
+ ## See Also
62
+
63
+ [camel-lite — root README](../../README.md)
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@alt-javascript/camel-lite-component-seda",
3
+ "version": "1.0.2",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.js"
7
+ },
8
+ "dependencies": {
9
+ "@alt-javascript/logger": "^3.0.7",
10
+ "@alt-javascript/config": "^3.0.7",
11
+ "@alt-javascript/common": "^3.0.7",
12
+ "@alt-javascript/camel-lite-core": "1.0.2"
13
+ },
14
+ "scripts": {
15
+ "test": "node --test"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/alt-javascript/camel-lite"
20
+ },
21
+ "author": "Craig Parravicini",
22
+ "contributors": [
23
+ "Claude (Anthropic)",
24
+ "Apache Camel — design inspiration and pattern source"
25
+ ],
26
+ "keywords": [
27
+ "alt-javascript",
28
+ "camel",
29
+ "camel-lite",
30
+ "eai",
31
+ "eip",
32
+ "integration",
33
+ "seda",
34
+ "async",
35
+ "messaging",
36
+ "queuing",
37
+ "component"
38
+ ],
39
+ "publishConfig": {
40
+ "registry": "https://registry.npmjs.org/",
41
+ "access": "public"
42
+ }
43
+ }
@@ -0,0 +1,24 @@
1
+ import { Component } from '@alt-javascript/camel-lite-core';
2
+ import SedaEndpoint from './SedaEndpoint.js';
3
+
4
+ class SedaComponent extends Component {
5
+ // Endpoint cache — ensures producer and consumer for the same URI share one queue
6
+ #endpoints = new Map();
7
+
8
+ createEndpoint(uri, remaining, parameters, context) {
9
+ if (this.#endpoints.has(uri)) {
10
+ return this.#endpoints.get(uri);
11
+ }
12
+ const endpoint = new SedaEndpoint(uri, remaining, parameters, context);
13
+ this.#endpoints.set(uri, endpoint);
14
+ return endpoint;
15
+ }
16
+
17
+ // Called by CamelContext.stop() to allow cleanup if needed
18
+ clearEndpoints() {
19
+ this.#endpoints.clear();
20
+ }
21
+ }
22
+
23
+ export { SedaComponent };
24
+ export default SedaComponent;
@@ -0,0 +1,86 @@
1
+ import { Consumer } from '@alt-javascript/camel-lite-core';
2
+ import { LoggerFactory } from '@alt-javascript/logger';
3
+
4
+ const log = LoggerFactory.getLogger('@alt-javascript/camel-lite/SedaConsumer');
5
+
6
+ class SedaConsumer extends Consumer {
7
+ #uri;
8
+ #context;
9
+ #pipeline;
10
+ #queue;
11
+ #concurrentConsumers;
12
+ #workerPromises = [];
13
+
14
+ constructor(uri, context, pipeline, queue, concurrentConsumers = 1) {
15
+ super();
16
+ this.#uri = uri;
17
+ this.#context = context;
18
+ this.#pipeline = pipeline;
19
+ this.#queue = queue;
20
+ this.#concurrentConsumers = concurrentConsumers;
21
+ }
22
+
23
+ get uri() {
24
+ return this.#uri;
25
+ }
26
+
27
+ async start() {
28
+ this.#context.registerConsumer(this.#uri, this);
29
+ log.info(`SEDA consumer started: ${this.#uri} (concurrentConsumers: ${this.#concurrentConsumers})`);
30
+
31
+ for (let i = 0; i < this.#concurrentConsumers; i++) {
32
+ const workerId = i;
33
+ const workerPromise = this.#runWorker(workerId);
34
+ this.#workerPromises.push(workerPromise);
35
+ }
36
+ }
37
+
38
+ async #runWorker(workerId) {
39
+ log.debug(`Worker ${workerId} started for ${this.#uri}`);
40
+ while (true) {
41
+ const exchange = await this.#queue.dequeue();
42
+ if (exchange === null) {
43
+ // Queue closed — drain complete
44
+ break;
45
+ }
46
+ log.debug(`Worker ${workerId} dequeued exchange ${exchange.in.messageId} from ${this.#uri}`);
47
+ try {
48
+ await this.#pipeline.run(exchange);
49
+ } catch (err) {
50
+ // Pipeline.run() captures errors into exchange.exception — this catch is a safety net
51
+ log.error(`Worker ${workerId} error processing exchange ${exchange.in.messageId}: ${err.message}`);
52
+ }
53
+ }
54
+ log.debug(`Worker ${workerId} exiting for ${this.#uri}`);
55
+ }
56
+
57
+ /**
58
+ * Poll the queue for an exchange, waiting at most timeoutMs milliseconds.
59
+ * Returns the Exchange if one is available, or null on timeout.
60
+ * Used by ConsumerTemplate to drain a message from outside the route pipeline.
61
+ * @param {number} [timeoutMs=5000]
62
+ * @returns {Promise<Exchange|null>}
63
+ */
64
+ async poll(timeoutMs = 5000) {
65
+ let timer;
66
+ const timeout = new Promise(resolve => {
67
+ timer = setTimeout(() => resolve(null), timeoutMs);
68
+ });
69
+ const item = await Promise.race([this.#queue.dequeue(), timeout]);
70
+ clearTimeout(timer);
71
+ return item;
72
+ }
73
+
74
+ async stop() {
75
+ // Close the queue — unblocks all waiting dequeues with null
76
+ this.#queue.close();
77
+ // Drain all worker loops
78
+ await Promise.all(this.#workerPromises);
79
+ this.#workerPromises = [];
80
+ this.#context.registerConsumer(this.#uri, null);
81
+ log.info(`SEDA consumer stopped: ${this.#uri}`);
82
+ }
83
+ }
84
+
85
+ export { SedaConsumer };
86
+ export default SedaConsumer;
@@ -0,0 +1,52 @@
1
+ import { Endpoint } from '@alt-javascript/camel-lite-core';
2
+ import SedaProducer from './SedaProducer.js';
3
+ import SedaConsumer from './SedaConsumer.js';
4
+ import SedaQueue from './SedaQueue.js';
5
+
6
+ class SedaEndpoint extends Endpoint {
7
+ #uri;
8
+ #context;
9
+ #concurrentConsumers;
10
+ #size;
11
+ #queue;
12
+
13
+ constructor(uri, remaining, parameters, context) {
14
+ super();
15
+ this.#uri = uri;
16
+ this.#context = context;
17
+
18
+ // Parse URI params — parameters is a URLSearchParams instance
19
+ const params = parameters instanceof URLSearchParams
20
+ ? parameters
21
+ : new URLSearchParams(typeof parameters === 'string' ? parameters : '');
22
+
23
+ this.#concurrentConsumers = Math.max(1, parseInt(params.get('concurrentConsumers') ?? '1', 10) || 1);
24
+ this.#size = Math.max(0, parseInt(params.get('size') ?? '0', 10) || 0);
25
+
26
+ // One queue per endpoint, shared by producer and all consumer workers
27
+ this.#queue = new SedaQueue(this.#size);
28
+ }
29
+
30
+ get uri() {
31
+ return this.#uri;
32
+ }
33
+
34
+ get concurrentConsumers() {
35
+ return this.#concurrentConsumers;
36
+ }
37
+
38
+ get size() {
39
+ return this.#size;
40
+ }
41
+
42
+ createProducer() {
43
+ return new SedaProducer(this.#uri, this.#queue);
44
+ }
45
+
46
+ createConsumer(pipeline) {
47
+ return new SedaConsumer(this.#uri, this.#context, pipeline, this.#queue, this.#concurrentConsumers);
48
+ }
49
+ }
50
+
51
+ export { SedaEndpoint };
52
+ export default SedaEndpoint;
@@ -0,0 +1,25 @@
1
+ import { Producer } from '@alt-javascript/camel-lite-core';
2
+
3
+ class SedaProducer extends Producer {
4
+ #uri;
5
+ #queue;
6
+
7
+ constructor(uri, queue) {
8
+ super();
9
+ this.#uri = uri;
10
+ this.#queue = queue;
11
+ }
12
+
13
+ get uri() {
14
+ return this.#uri;
15
+ }
16
+
17
+ async send(exchange) {
18
+ // Fire-and-forget: enqueue and return immediately.
19
+ // SedaQueueFullError propagates to the caller if the queue is at capacity.
20
+ this.#queue.enqueue(exchange);
21
+ }
22
+ }
23
+
24
+ export { SedaProducer };
25
+ export default SedaProducer;
@@ -0,0 +1,56 @@
1
+ import { SedaQueueFullError } from '@alt-javascript/camel-lite-core';
2
+
3
+ class SedaQueue {
4
+ #items = [];
5
+ #waiters = [];
6
+ #closed = false;
7
+ #maxSize;
8
+
9
+ constructor(maxSize = 0) {
10
+ this.#maxSize = maxSize;
11
+ }
12
+
13
+ enqueue(item) {
14
+ if (this.#closed) {
15
+ throw new Error('SedaQueue is closed');
16
+ }
17
+ if (this.#maxSize > 0 && this.#items.length >= this.#maxSize) {
18
+ throw new SedaQueueFullError(this.#maxSize);
19
+ }
20
+ if (this.#waiters.length > 0) {
21
+ // A consumer is already waiting — deliver directly without touching the queue
22
+ this.#waiters.shift().resolve(item);
23
+ } else {
24
+ this.#items.push(item);
25
+ }
26
+ }
27
+
28
+ dequeue() {
29
+ if (this.#items.length > 0) {
30
+ return Promise.resolve(this.#items.shift());
31
+ }
32
+ if (this.#closed) {
33
+ return Promise.resolve(null);
34
+ }
35
+ return new Promise(resolve => this.#waiters.push({ resolve }));
36
+ }
37
+
38
+ close() {
39
+ this.#closed = true;
40
+ for (const waiter of this.#waiters) {
41
+ waiter.resolve(null);
42
+ }
43
+ this.#waiters = [];
44
+ }
45
+
46
+ get size() {
47
+ return this.#items.length;
48
+ }
49
+
50
+ get closed() {
51
+ return this.#closed;
52
+ }
53
+ }
54
+
55
+ export { SedaQueue };
56
+ export default SedaQueue;
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { SedaComponent } from './SedaComponent.js';
2
+ export { SedaEndpoint } from './SedaEndpoint.js';
3
+ export { SedaProducer } from './SedaProducer.js';
4
+ export { SedaConsumer } from './SedaConsumer.js';
5
+ export { SedaQueue } from './SedaQueue.js';
6
+
7
+ import SedaComponent from './SedaComponent.js';
8
+ export default SedaComponent;
@@ -0,0 +1,155 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { CamelContext, Exchange, RouteDefinition, SedaQueueFullError } from '@alt-javascript/camel-lite-core';
4
+ import { DirectComponent } from '@alt-javascript/camel-lite-component-direct';
5
+ import { SedaComponent, SedaQueue, SedaProducer, SedaConsumer, SedaEndpoint } from '@alt-javascript/camel-lite-component-seda';
6
+
7
+ function makeLatch(count) {
8
+ let resolve;
9
+ let remaining = count;
10
+ const promise = new Promise(r => { resolve = r; });
11
+ const tick = () => { if (--remaining <= 0) resolve(); };
12
+ return { promise, tick };
13
+ }
14
+
15
+ describe('Concurrent consumers', () => {
16
+ it('4 concurrent workers process 100 exchanges — all processed, none lost', async () => {
17
+ const { promise: latch, tick } = makeLatch(100);
18
+ let counter = 0;
19
+
20
+ const context = new CamelContext();
21
+ context.addComponent('direct', new DirectComponent());
22
+ context.addComponent('seda', new SedaComponent());
23
+
24
+ const routeA = new RouteDefinition('direct:entry');
25
+ routeA.to('seda:work?concurrentConsumers=4');
26
+
27
+ const routeB = new RouteDefinition('seda:work?concurrentConsumers=4');
28
+ routeB.process((exchange) => {
29
+ counter++;
30
+ tick();
31
+ });
32
+
33
+ context.addRoutes({ configure() {}, getRoutes() { return [routeA, routeB]; } });
34
+ await context.start();
35
+
36
+ const entryConsumer = context.getConsumer('direct:entry');
37
+ for (let i = 0; i < 100; i++) {
38
+ const exchange = new Exchange();
39
+ exchange.in.body = i;
40
+ await entryConsumer.process(exchange);
41
+ }
42
+
43
+ await latch;
44
+ await context.stop();
45
+
46
+ assert.equal(counter, 100, `expected 100 processed, got ${counter}`);
47
+ });
48
+
49
+ it('stop() with 4 workers in-flight drains all workers cleanly', async () => {
50
+ const TOTAL = 40;
51
+ const { promise: latch, tick } = makeLatch(TOTAL);
52
+ let counter = 0;
53
+
54
+ const context = new CamelContext();
55
+ context.addComponent('direct', new DirectComponent());
56
+ context.addComponent('seda', new SedaComponent());
57
+
58
+ const routeA = new RouteDefinition('direct:entry');
59
+ routeA.to('seda:drain?concurrentConsumers=4');
60
+
61
+ const routeB = new RouteDefinition('seda:drain?concurrentConsumers=4');
62
+ routeB.process(async (exchange) => {
63
+ // Simulate a tiny bit of async work
64
+ await new Promise(r => setTimeout(r, 5));
65
+ counter++;
66
+ tick();
67
+ });
68
+
69
+ context.addRoutes({ configure() {}, getRoutes() { return [routeA, routeB]; } });
70
+ await context.start();
71
+
72
+ const entryConsumer = context.getConsumer('direct:entry');
73
+ for (let i = 0; i < TOTAL; i++) {
74
+ const exchange = new Exchange();
75
+ exchange.in.body = i;
76
+ await entryConsumer.process(exchange);
77
+ }
78
+
79
+ // Wait for all to be processed, then stop
80
+ await latch;
81
+ const stopStart = Date.now();
82
+ await context.stop();
83
+ const stopDuration = Date.now() - stopStart;
84
+
85
+ assert.equal(counter, TOTAL, `expected ${TOTAL} processed, got ${counter}`);
86
+ assert.ok(stopDuration < 2000, `stop() should be fast after drain, took ${stopDuration}ms`);
87
+ });
88
+ });
89
+
90
+ describe('Backpressure', () => {
91
+ it('size=2: 3rd enqueue throws SedaQueueFullError', async () => {
92
+ const context = new CamelContext();
93
+ context.addComponent('direct', new DirectComponent());
94
+ context.addComponent('seda', new SedaComponent());
95
+
96
+ // Use a very slow consumer so items stay in queue long enough to hit the limit
97
+ const { promise: latch, tick } = makeLatch(2);
98
+
99
+ const routeA = new RouteDefinition('direct:entry');
100
+ routeA.to('seda:bounded?size=2');
101
+
102
+ const routeB = new RouteDefinition('seda:bounded?size=2');
103
+ routeB.process(async (exchange) => {
104
+ // Slow enough that 3 items could pile up before processing starts
105
+ await new Promise(r => setTimeout(r, 50));
106
+ tick();
107
+ });
108
+
109
+ context.addRoutes({ configure() {}, getRoutes() { return [routeA, routeB]; } });
110
+ await context.start();
111
+
112
+ // To reliably test backpressure, enqueue directly to a queue with no running consumer
113
+ const queue = new SedaQueue(2);
114
+ queue.enqueue(new Exchange());
115
+ queue.enqueue(new Exchange());
116
+
117
+ let threw = false;
118
+ try {
119
+ queue.enqueue(new Exchange());
120
+ } catch (err) {
121
+ threw = true;
122
+ assert.ok(err instanceof SedaQueueFullError, `expected SedaQueueFullError, got ${err.constructor.name}`);
123
+ assert.equal(err.maxSize, 2);
124
+ assert.ok(err.message.includes('2'));
125
+ }
126
+
127
+ assert.ok(threw, 'should have thrown SedaQueueFullError on 3rd enqueue');
128
+ await context.stop();
129
+ });
130
+
131
+ it('SedaQueueFullError propagates through send() to caller', async () => {
132
+ const queue = new SedaQueue(1);
133
+ const producer = new SedaProducer('seda:bounded', queue);
134
+
135
+ const ex1 = new Exchange();
136
+ const ex2 = new Exchange();
137
+
138
+ await producer.send(ex1); // succeeds — queue has 1 item
139
+
140
+ await assert.rejects(
141
+ () => producer.send(ex2),
142
+ (err) => {
143
+ assert.ok(err instanceof SedaQueueFullError);
144
+ assert.equal(err.maxSize, 1);
145
+ return true;
146
+ }
147
+ );
148
+ });
149
+
150
+ it('SedaEndpoint with size param creates bounded queue', () => {
151
+ const ctx = new CamelContext();
152
+ const ep = new SedaEndpoint('seda:bounded', 'bounded', new URLSearchParams('size=5'), ctx);
153
+ assert.equal(ep.size, 5);
154
+ });
155
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Exchange, Message, CamelContext, Component, Pipeline, SedaQueueFullError } from '@alt-javascript/camel-lite-core';
4
+ import { SedaComponent, SedaEndpoint, SedaProducer, SedaConsumer, SedaQueue } from '@alt-javascript/camel-lite-component-seda';
5
+
6
+ describe('cross-package import integration', () => {
7
+ it('Exchange imported from camel-lite-core constructs correctly', () => {
8
+ const ex = new Exchange();
9
+ assert.equal(ex.pattern, 'InOnly');
10
+ assert.ok(ex.in instanceof Message);
11
+ });
12
+
13
+ it('SedaComponent is a subclass of Component', () => {
14
+ const c = new SedaComponent();
15
+ assert.ok(c instanceof Component);
16
+ });
17
+
18
+ it('SedaQueueFullError imported from camel-lite-core', () => {
19
+ const err = new SedaQueueFullError(5);
20
+ assert.ok(err instanceof Error);
21
+ assert.equal(err.name, 'SedaQueueFullError');
22
+ assert.equal(err.maxSize, 5);
23
+ });
24
+
25
+ it('SedaComponent.createEndpoint returns a SedaEndpoint', () => {
26
+ const c = new SedaComponent();
27
+ const ctx = new CamelContext();
28
+ const ep = c.createEndpoint('seda:test', 'test', new URLSearchParams(), ctx);
29
+ assert.ok(ep instanceof SedaEndpoint);
30
+ });
31
+
32
+ it('SedaEndpoint.createProducer returns SedaProducer', () => {
33
+ const ctx = new CamelContext();
34
+ const ep = new SedaEndpoint('seda:test', 'test', new URLSearchParams(), ctx);
35
+ assert.ok(ep.createProducer() instanceof SedaProducer);
36
+ });
37
+
38
+ it('SedaEndpoint.createConsumer returns SedaConsumer', () => {
39
+ const ctx = new CamelContext();
40
+ const ep = new SedaEndpoint('seda:test', 'test', new URLSearchParams(), ctx);
41
+ assert.ok(ep.createConsumer(new Pipeline([])) instanceof SedaConsumer);
42
+ });
43
+ });
@@ -0,0 +1,98 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { SedaQueue } from '../src/SedaQueue.js';
4
+ import { SedaQueueFullError } from '@alt-javascript/camel-lite-core';
5
+
6
+ describe('SedaQueue', () => {
7
+ it('enqueue then dequeue returns the item', async () => {
8
+ const q = new SedaQueue();
9
+ q.enqueue('hello');
10
+ const item = await q.dequeue();
11
+ assert.equal(item, 'hello');
12
+ });
13
+
14
+ it('dequeue before enqueue — resolves when item arrives', async () => {
15
+ const q = new SedaQueue();
16
+ const deqPromise = q.dequeue();
17
+ q.enqueue('late');
18
+ const item = await deqPromise;
19
+ assert.equal(item, 'late');
20
+ });
21
+
22
+ it('multiple items dequeued in FIFO order', async () => {
23
+ const q = new SedaQueue();
24
+ q.enqueue(1);
25
+ q.enqueue(2);
26
+ q.enqueue(3);
27
+ assert.equal(await q.dequeue(), 1);
28
+ assert.equal(await q.dequeue(), 2);
29
+ assert.equal(await q.dequeue(), 3);
30
+ });
31
+
32
+ it('close() resolves all waiting consumers with null', async () => {
33
+ const q = new SedaQueue();
34
+ const p1 = q.dequeue();
35
+ const p2 = q.dequeue();
36
+ q.close();
37
+ assert.equal(await p1, null);
38
+ assert.equal(await p2, null);
39
+ });
40
+
41
+ it('dequeue on a closed empty queue returns null immediately', async () => {
42
+ const q = new SedaQueue();
43
+ q.close();
44
+ const item = await q.dequeue();
45
+ assert.equal(item, null);
46
+ });
47
+
48
+ it('enqueue after close throws', () => {
49
+ const q = new SedaQueue();
50
+ q.close();
51
+ assert.throws(() => q.enqueue('x'), { message: 'SedaQueue is closed' });
52
+ });
53
+
54
+ it('size reflects queued items', () => {
55
+ const q = new SedaQueue();
56
+ assert.equal(q.size, 0);
57
+ q.enqueue('a');
58
+ q.enqueue('b');
59
+ assert.equal(q.size, 2);
60
+ });
61
+
62
+ it('closed reflects queue state', () => {
63
+ const q = new SedaQueue();
64
+ assert.equal(q.closed, false);
65
+ q.close();
66
+ assert.equal(q.closed, true);
67
+ });
68
+
69
+ it('maxSize=2: enqueue 2 succeeds, 3rd throws SedaQueueFullError', () => {
70
+ const q = new SedaQueue(2);
71
+ q.enqueue('a');
72
+ q.enqueue('b');
73
+ assert.throws(
74
+ () => q.enqueue('c'),
75
+ (err) => {
76
+ assert.ok(err instanceof SedaQueueFullError);
77
+ assert.equal(err.name, 'SedaQueueFullError');
78
+ assert.equal(err.maxSize, 2);
79
+ return true;
80
+ }
81
+ );
82
+ });
83
+
84
+ it('maxSize=0 (unlimited): accepts many items without throwing', () => {
85
+ const q = new SedaQueue(0);
86
+ for (let i = 0; i < 1000; i++) q.enqueue(i);
87
+ assert.equal(q.size, 1000);
88
+ });
89
+
90
+ it('direct delivery to waiting consumer bypasses items array (size stays 0)', async () => {
91
+ const q = new SedaQueue();
92
+ const p = q.dequeue(); // waiter registered
93
+ assert.equal(q.size, 0);
94
+ q.enqueue('direct');
95
+ assert.equal(q.size, 0); // delivered directly, not buffered
96
+ assert.equal(await p, 'direct');
97
+ });
98
+ });
@@ -0,0 +1,194 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { CamelContext, Exchange, Pipeline, RouteDefinition } from '@alt-javascript/camel-lite-core';
4
+ import { DirectComponent } from '@alt-javascript/camel-lite-component-direct';
5
+ import { SedaComponent, SedaEndpoint, SedaProducer, SedaConsumer, SedaQueue } from '@alt-javascript/camel-lite-component-seda';
6
+
7
+ // Promise latch — resolves once `count` items have been processed
8
+ function makeLatch(count) {
9
+ let resolve;
10
+ let remaining = count;
11
+ const promise = new Promise(r => { resolve = r; });
12
+ const tick = () => { if (--remaining <= 0) resolve(); };
13
+ return { promise, tick };
14
+ }
15
+
16
+ describe('SedaComponent', () => {
17
+ it('can be constructed', () => {
18
+ const c = new SedaComponent();
19
+ assert.ok(c instanceof SedaComponent);
20
+ });
21
+
22
+ it('createEndpoint returns SedaEndpoint', () => {
23
+ const c = new SedaComponent();
24
+ const ctx = new CamelContext();
25
+ const ep = c.createEndpoint('seda:work', 'work', new URLSearchParams(), ctx);
26
+ assert.ok(ep instanceof SedaEndpoint);
27
+ assert.equal(ep.uri, 'seda:work');
28
+ });
29
+
30
+ it('createEndpoint returns same endpoint for same URI (endpoint cache)', () => {
31
+ const c = new SedaComponent();
32
+ const ctx = new CamelContext();
33
+ const ep1 = c.createEndpoint('seda:work', 'work', new URLSearchParams(), ctx);
34
+ const ep2 = c.createEndpoint('seda:work', 'work', new URLSearchParams(), ctx);
35
+ assert.strictEqual(ep1, ep2, 'same URI must return same endpoint (shared queue)');
36
+ });
37
+ });
38
+
39
+ describe('SedaEndpoint', () => {
40
+ it('createProducer returns SedaProducer', () => {
41
+ const ctx = new CamelContext();
42
+ const ep = new SedaEndpoint('seda:work', 'work', new URLSearchParams(), ctx);
43
+ const producer = ep.createProducer();
44
+ assert.ok(producer instanceof SedaProducer);
45
+ assert.equal(producer.uri, 'seda:work');
46
+ });
47
+
48
+ it('createConsumer returns SedaConsumer', () => {
49
+ const ctx = new CamelContext();
50
+ const ep = new SedaEndpoint('seda:work', 'work', new URLSearchParams(), ctx);
51
+ const pipeline = new Pipeline([]);
52
+ const consumer = ep.createConsumer(pipeline);
53
+ assert.ok(consumer instanceof SedaConsumer);
54
+ assert.equal(consumer.uri, 'seda:work');
55
+ });
56
+
57
+ it('concurrentConsumers defaults to 1', () => {
58
+ const ctx = new CamelContext();
59
+ const ep = new SedaEndpoint('seda:work', 'work', new URLSearchParams(), ctx);
60
+ assert.equal(ep.concurrentConsumers, 1);
61
+ });
62
+
63
+ it('concurrentConsumers parsed from URI param', () => {
64
+ const ctx = new CamelContext();
65
+ const ep = new SedaEndpoint('seda:work', 'work', new URLSearchParams('concurrentConsumers=4'), ctx);
66
+ assert.equal(ep.concurrentConsumers, 4);
67
+ });
68
+
69
+ it('size defaults to 0 (unlimited)', () => {
70
+ const ctx = new CamelContext();
71
+ const ep = new SedaEndpoint('seda:work', 'work', new URLSearchParams(), ctx);
72
+ assert.equal(ep.size, 0);
73
+ });
74
+ });
75
+
76
+ describe('SedaProducer', () => {
77
+ it('send() enqueues without awaiting downstream processing', async () => {
78
+ const queue = new SedaQueue();
79
+ const producer = new SedaProducer('seda:work', queue);
80
+ const exchange = new Exchange();
81
+
82
+ // Consumer loop waits for item
83
+ const dequeuePromise = queue.dequeue();
84
+ await producer.send(exchange);
85
+
86
+ // send() returned — now await the dequeue
87
+ const dequeued = await dequeuePromise;
88
+ assert.strictEqual(dequeued, exchange);
89
+ });
90
+ });
91
+
92
+ describe('SedaConsumer lifecycle', () => {
93
+ it('start() registers consumer with context', async () => {
94
+ const ctx = new CamelContext();
95
+ const queue = new SedaQueue();
96
+ const pipeline = new Pipeline([]);
97
+ const consumer = new SedaConsumer('seda:test', ctx, pipeline, queue, 1);
98
+
99
+ await consumer.start();
100
+ assert.strictEqual(ctx.getConsumer('seda:test'), consumer);
101
+ await consumer.stop();
102
+ });
103
+
104
+ it('stop() deregisters consumer from context', async () => {
105
+ const ctx = new CamelContext();
106
+ const queue = new SedaQueue();
107
+ const pipeline = new Pipeline([]);
108
+ const consumer = new SedaConsumer('seda:test', ctx, pipeline, queue, 1);
109
+
110
+ await consumer.start();
111
+ await consumer.stop();
112
+ assert.equal(ctx.getConsumer('seda:test'), null);
113
+ });
114
+ });
115
+
116
+ describe('SEDA integration: single consumer, 10 exchanges', () => {
117
+ it('all 10 exchanges processed and drained by stop()', async () => {
118
+ const { promise: latch, tick } = makeLatch(10);
119
+ const processed = [];
120
+
121
+ const context = new CamelContext();
122
+ context.addComponent('direct', new DirectComponent());
123
+ context.addComponent('seda', new SedaComponent());
124
+
125
+ // Entry route: direct:entry → seda:work (fire-and-forget)
126
+ const routeA = new RouteDefinition('direct:entry');
127
+ routeA.process((exchange) => { exchange.in.body = `msg-${exchange.in.body}`; });
128
+ routeA.to('seda:work');
129
+
130
+ // Worker route: seda:work → process
131
+ const routeB = new RouteDefinition('seda:work');
132
+ routeB.process((exchange) => {
133
+ processed.push(exchange.in.body);
134
+ tick();
135
+ });
136
+
137
+ context.addRoutes({ configure() {}, getRoutes() { return [routeA, routeB]; } });
138
+ await context.start();
139
+
140
+ const entryConsumer = context.getConsumer('direct:entry');
141
+
142
+ // Enqueue 10 exchanges via direct:entry → seda:work
143
+ for (let i = 0; i < 10; i++) {
144
+ const exchange = new Exchange();
145
+ exchange.in.body = i;
146
+ await entryConsumer.process(exchange);
147
+ }
148
+
149
+ // Wait for all 10 to be processed by the seda worker
150
+ await latch;
151
+ // Drain workers
152
+ await context.stop();
153
+
154
+ assert.equal(processed.length, 10, `expected 10 processed, got ${processed.length}`);
155
+ assert.ok(!context.getConsumer('seda:work'), 'consumer deregistered after stop');
156
+ });
157
+
158
+ it('pipeline error in worker does not stop the worker — subsequent exchanges still processed', async () => {
159
+ const { promise: latch, tick } = makeLatch(5);
160
+ const processed = [];
161
+
162
+ const context = new CamelContext();
163
+ context.addComponent('direct', new DirectComponent());
164
+ context.addComponent('seda', new SedaComponent());
165
+
166
+ const routeA = new RouteDefinition('direct:entry');
167
+ routeA.to('seda:work');
168
+
169
+ let callCount = 0;
170
+ const routeB = new RouteDefinition('seda:work');
171
+ routeB.process((exchange) => {
172
+ callCount++;
173
+ if (callCount === 2) throw new Error('deliberate worker error');
174
+ processed.push(exchange.in.body);
175
+ tick();
176
+ });
177
+
178
+ context.addRoutes({ configure() {}, getRoutes() { return [routeA, routeB]; } });
179
+ await context.start();
180
+
181
+ const entryConsumer = context.getConsumer('direct:entry');
182
+ for (let i = 0; i < 6; i++) {
183
+ const exchange = new Exchange();
184
+ exchange.in.body = `item-${i}`;
185
+ await entryConsumer.process(exchange);
186
+ }
187
+
188
+ await latch;
189
+ await context.stop();
190
+
191
+ // 1 of 6 failed mid-pipeline (exception captured in exchange); 5 completed
192
+ assert.equal(processed.length, 5);
193
+ });
194
+ });