node-red-contrib-event-calc 0.1.2 → 2.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 +25 -0
- package/README.md +50 -51
- package/nodes/event-cache.js +84 -139
- package/nodes/event-calc.html +46 -22
- package/nodes/event-calc.js +82 -26
- package/nodes/event-chart.html +239 -0
- package/nodes/event-chart.js +106 -0
- package/nodes/event-json.html +58 -0
- package/nodes/event-json.js +69 -0
- package/nodes/event-simulator.html +156 -0
- package/nodes/event-simulator.js +185 -0
- package/nodes/event-topic.html +16 -30
- package/nodes/event-topic.js +33 -40
- package/package.json +24 -10
- package/playwright.config.js +22 -0
- package/tests/external-trigger.spec.js +141 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Personal Use License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Holger Amort
|
|
4
|
+
|
|
5
|
+
This software is provided for personal, non-commercial use only.
|
|
6
|
+
|
|
7
|
+
You may:
|
|
8
|
+
- Use this software for personal projects
|
|
9
|
+
- Modify the software for your own use
|
|
10
|
+
- Share the software with attribution
|
|
11
|
+
|
|
12
|
+
You may not:
|
|
13
|
+
- Use this software for commercial purposes without permission
|
|
14
|
+
- Sell or sublicense this software
|
|
15
|
+
- Remove this license notice
|
|
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.
|
|
24
|
+
|
|
25
|
+
For commercial licensing inquiries, contact the author.
|
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# node-red-contrib-event-calc
|
|
2
2
|
|
|
3
|
-
Node-RED nodes for event caching and calculations with
|
|
3
|
+
Node-RED nodes for event caching and streaming calculations with a local pub/sub event hub.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
This package provides a
|
|
7
|
+
This package provides a local in-memory event hub with topic-based publish/subscribe and latest-value caching for reactive data flows within Node-RED. Stream data from MQTT, OPC-UA, or any source, then perform calculations that trigger automatically when values update.
|
|
8
8
|
|
|
9
9
|
## Architecture
|
|
10
10
|
|
|
@@ -13,14 +13,14 @@ This package provides a central cache for event/streaming data values with react
|
|
|
13
13
|
│ event-cache (config node) │
|
|
14
14
|
│ • Stores: Map<topic, {value, ts, metadata}> │
|
|
15
15
|
│ • Event emitter for topic updates │
|
|
16
|
-
│ •
|
|
16
|
+
│ • LRU eviction, optional TTL │
|
|
17
17
|
└──────────────────────────────────────────────────────────────┘
|
|
18
18
|
│ │ │
|
|
19
19
|
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
|
|
20
20
|
│event-in │ │event- │ │event- │
|
|
21
21
|
│ │ │topic │ │calc │
|
|
22
22
|
│ pushes │ │subscribes│ │multi-sub│
|
|
23
|
-
│to cache │ │to
|
|
23
|
+
│to cache │ │to topic │ │+ expr │
|
|
24
24
|
└─────────┘ └─────────┘ └─────────┘
|
|
25
25
|
```
|
|
26
26
|
|
|
@@ -53,30 +53,30 @@ The original message passes through, allowing insertion into existing flows.
|
|
|
53
53
|
|
|
54
54
|
### event-topic
|
|
55
55
|
|
|
56
|
-
Subscribes to a topic
|
|
56
|
+
Subscribes to a topic and outputs when that topic updates.
|
|
57
57
|
|
|
58
58
|
**Properties:**
|
|
59
|
-
- **Topic
|
|
59
|
+
- **Topic**: Exact topic to subscribe to
|
|
60
60
|
- **Output Format**:
|
|
61
61
|
- *Value only*: `msg.payload` = value
|
|
62
62
|
- *Full entry*: `msg.payload` = `{value, ts, metadata}`
|
|
63
|
-
- *All matching*: `msg.payload` = `{topic1: value1, topic2: value2, ...}`
|
|
64
63
|
- **Output on deploy**: Emit cached values when flow starts
|
|
65
64
|
|
|
66
65
|
**Dynamic control via input:**
|
|
67
|
-
- `msg.
|
|
68
|
-
- `msg.payload = 'refresh'`: Output
|
|
66
|
+
- `msg.topic`: Change subscription topic
|
|
67
|
+
- `msg.payload = 'refresh'`: Output current cached value
|
|
69
68
|
|
|
70
69
|
### event-calc
|
|
71
70
|
|
|
72
71
|
Subscribes to multiple topics and evaluates an expression when values update.
|
|
73
72
|
|
|
74
73
|
**Properties:**
|
|
75
|
-
- **Input Variables**: Map variable names to
|
|
74
|
+
- **Input Variables**: Map variable names to topics
|
|
76
75
|
- **Expression**: JavaScript expression using the variables
|
|
77
76
|
- **Trigger**: When to calculate
|
|
78
77
|
- *Any input updates*: Calculate on every update
|
|
79
78
|
- *Only when all inputs have values*: Wait for all values
|
|
79
|
+
- **External Trigger**: When enabled, any incoming message triggers calculation using cached values
|
|
80
80
|
|
|
81
81
|
**Output:**
|
|
82
82
|
```json
|
|
@@ -92,19 +92,27 @@ Subscribes to multiple topics and evaluates an expression when values update.
|
|
|
92
92
|
}
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
### event-json
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
Bidirectional JSON envelope converter for MQTT messaging.
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
| `sensors/*` | `sensors/temp`, `sensors/room1/temp` | `sensors` (nothing after /) |
|
|
103
|
-
| `*/temp` | `room/temp`, `sensors/temp` | `temp` (nothing before /) |
|
|
104
|
-
| `*` | Any topic with 1+ chars | Empty string |
|
|
99
|
+
**Behavior:**
|
|
100
|
+
- **Unwrap**: If payload is `{value, topic?, timestamp?}`, extracts to msg properties
|
|
101
|
+
- **Wrap**: If payload is any other value, wraps as `{timestamp, topic, value}`
|
|
105
102
|
|
|
106
|
-
|
|
107
|
-
|
|
103
|
+
**Usage:**
|
|
104
|
+
```
|
|
105
|
+
[MQTT in] → [event-json] → [event-in] (unwrap JSON from broker)
|
|
106
|
+
[event-topic] → [event-json] → [MQTT out] (wrap for broker)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### event-simulator
|
|
110
|
+
|
|
111
|
+
Generates simulated data for testing. Supports sine waves, random values, and ramps.
|
|
112
|
+
|
|
113
|
+
### event-chart
|
|
114
|
+
|
|
115
|
+
Real-time charting node for visualizing cached event data.
|
|
108
116
|
|
|
109
117
|
## Examples
|
|
110
118
|
|
|
@@ -121,13 +129,21 @@ Two wildcards are supported:
|
|
|
121
129
|
trigger: all
|
|
122
130
|
```
|
|
123
131
|
|
|
124
|
-
###
|
|
132
|
+
### Time-based Calculations (External Trigger)
|
|
125
133
|
|
|
126
134
|
```
|
|
127
|
-
[
|
|
135
|
+
[inject: every 1 min] → [event-calc (external trigger)] → [MQTT out]
|
|
136
|
+
inputs: a = sensors/power
|
|
137
|
+
b = sensors/voltage
|
|
138
|
+
expression: a * b
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### MQTT Round-trip with JSON Envelope
|
|
128
142
|
|
|
129
|
-
|
|
130
|
-
|
|
143
|
+
```
|
|
144
|
+
[MQTT in] → [event-json] → [event-in] → [cache]
|
|
145
|
+
|
|
146
|
+
[event-calc] → [event-json] → [MQTT out]
|
|
131
147
|
```
|
|
132
148
|
|
|
133
149
|
### Calculate Power (Voltage × Current)
|
|
@@ -188,27 +204,6 @@ Two wildcards are supported:
|
|
|
188
204
|
| `ifelse(a > b, 'high', 'low')` | Conditional |
|
|
189
205
|
| `pctChange(a, b)` | % change from b to a |
|
|
190
206
|
|
|
191
|
-
## Scalability
|
|
192
|
-
|
|
193
|
-
The event-cache is optimized for high subscriber counts:
|
|
194
|
-
|
|
195
|
-
| Subscription Type | Lookup Complexity | Best For |
|
|
196
|
-
|-------------------|-------------------|----------|
|
|
197
|
-
| Exact topic (e.g., `sensors/room1/temp`) | O(1) | High-frequency updates, many subscribers |
|
|
198
|
-
| Wildcard pattern (e.g., `sensors/*`) | O(w) | Flexible matching, fewer patterns |
|
|
199
|
-
|
|
200
|
-
Where `w` = number of wildcard subscriptions (typically much smaller than total subscribers).
|
|
201
|
-
|
|
202
|
-
**Example performance:**
|
|
203
|
-
- 1000 exact subscriptions to different topics: O(1) per update
|
|
204
|
-
- 10 wildcard patterns + 1000 exact subscriptions: O(10) per update
|
|
205
|
-
- Pure wildcard subscriptions: O(n) per update
|
|
206
|
-
|
|
207
|
-
**Recommendations for high scale:**
|
|
208
|
-
- Prefer exact topic matches when possible
|
|
209
|
-
- Use wildcards sparingly for monitoring/logging
|
|
210
|
-
- Check stats endpoint: `GET /event-cache/:id/stats`
|
|
211
|
-
|
|
212
207
|
## API (for custom nodes)
|
|
213
208
|
|
|
214
209
|
The event-cache node exposes methods for programmatic access:
|
|
@@ -223,12 +218,8 @@ cache.setValue('topic/path', 42, { source: 'sensor' });
|
|
|
223
218
|
const entry = cache.getValue('topic/path');
|
|
224
219
|
// { value: 42, ts: 1704000000000, metadata: { source: 'sensor' } }
|
|
225
220
|
|
|
226
|
-
// Get matching values
|
|
227
|
-
const temps = cache.getMatching('sensors/*');
|
|
228
|
-
// Map { 'sensors/room1/temp' => {...}, 'sensors/room2/temp' => {...} }
|
|
229
|
-
|
|
230
221
|
// Subscribe to updates
|
|
231
|
-
const subId = cache.subscribe('sensors
|
|
222
|
+
const subId = cache.subscribe('sensors/room1/temp', (topic, entry) => {
|
|
232
223
|
console.log(`${topic} = ${entry.value}`);
|
|
233
224
|
});
|
|
234
225
|
|
|
@@ -242,6 +233,14 @@ const topics = cache.getTopics();
|
|
|
242
233
|
cache.clear();
|
|
243
234
|
```
|
|
244
235
|
|
|
236
|
+
## HTTP Admin Endpoints
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
GET /event-cache/:id/stats - Cache statistics
|
|
240
|
+
GET /event-cache/:id/topics - List all topics
|
|
241
|
+
POST /event-cache/:id/clear - Clear cache
|
|
242
|
+
```
|
|
243
|
+
|
|
245
244
|
## License
|
|
246
245
|
|
|
247
|
-
|
|
246
|
+
Personal Use License - See [LICENSE](LICENSE) file.
|
package/nodes/event-cache.js
CHANGED
|
@@ -3,53 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Features:
|
|
5
5
|
* - Map<topic, {value, ts, metadata}> for caching latest values
|
|
6
|
+
* - Stored in global context for visibility in sidebar
|
|
6
7
|
* - EventEmitter for notifying subscribers on updates
|
|
7
|
-
* -
|
|
8
|
+
* - Exact topic matching for subscriptions
|
|
8
9
|
* - LRU eviction when maxEntries exceeded
|
|
9
10
|
* - Reference counting for cleanup
|
|
10
11
|
*/
|
|
11
12
|
module.exports = function(RED) {
|
|
12
13
|
const EventEmitter = require('events');
|
|
13
14
|
|
|
14
|
-
// Shared
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Check if a pattern contains wildcards
|
|
19
|
-
* @param {string} pattern - Topic pattern
|
|
20
|
-
* @returns {boolean} - True if pattern contains * or ?
|
|
21
|
-
*/
|
|
22
|
-
function hasWildcard(pattern) {
|
|
23
|
-
return pattern && (pattern.includes('*') || pattern.includes('?'));
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Convert topic pattern to RegExp
|
|
28
|
-
*
|
|
29
|
-
* Wildcards:
|
|
30
|
-
* - '*' (asterisk): Matches one or more characters
|
|
31
|
-
* Example: 'sensors/*' matches 'sensors/temp', 'sensors/room1/temp'
|
|
32
|
-
*
|
|
33
|
-
* - '?' (question mark): Matches exactly one character
|
|
34
|
-
* Example: 'sensor?' matches 'sensor1', 'sensorA' but NOT 'sensor' or 'sensor12'
|
|
35
|
-
*
|
|
36
|
-
* @param {string} pattern - Topic pattern with wildcards
|
|
37
|
-
* @returns {RegExp} - Regular expression for matching
|
|
38
|
-
*/
|
|
39
|
-
function patternToRegex(pattern) {
|
|
40
|
-
// Handle empty pattern or just *
|
|
41
|
-
if (!pattern || pattern === '*') {
|
|
42
|
-
return /^.+$/;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Escape regex special characters except our wildcards (* and ?)
|
|
46
|
-
let regexStr = pattern
|
|
47
|
-
.replace(/[.^$|()[\]{}\\+#/]/g, '\\$&') // Escape regex special chars (including /)
|
|
48
|
-
.replace(/\?/g, '.') // ? matches exactly one character
|
|
49
|
-
.replace(/\*/g, '.+'); // * matches one or more characters
|
|
50
|
-
|
|
51
|
-
return new RegExp(`^${regexStr}$`);
|
|
52
|
-
}
|
|
15
|
+
// Shared instances for event emitters and subscriptions (not stored in context)
|
|
16
|
+
const sharedInstances = new Map();
|
|
53
17
|
|
|
54
18
|
function EventCacheNode(config) {
|
|
55
19
|
RED.nodes.createNode(this, config);
|
|
@@ -59,36 +23,47 @@ module.exports = function(RED) {
|
|
|
59
23
|
node.maxEntries = parseInt(config.maxEntries) || 10000;
|
|
60
24
|
node.ttl = parseInt(config.ttl) || 0; // 0 = no expiry
|
|
61
25
|
|
|
62
|
-
//
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
26
|
+
// Context key for storing cache data (visible in sidebar)
|
|
27
|
+
const contextKey = `eventCache_${node.name.replace(/[^a-zA-Z0-9_]/g, '_')}`;
|
|
28
|
+
const globalContext = node.context().global;
|
|
29
|
+
|
|
30
|
+
// Create or get shared instance for emitters/subscriptions (not serializable)
|
|
31
|
+
const instanceKey = node.id;
|
|
32
|
+
if (!sharedInstances.has(instanceKey)) {
|
|
33
|
+
sharedInstances.set(instanceKey, {
|
|
67
34
|
emitter: new EventEmitter(),
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
// - wildcardSubscriptions: Map<subId, {pattern, regex, callback}> for pattern matching
|
|
71
|
-
exactSubscriptions: new Map(),
|
|
72
|
-
wildcardSubscriptions: new Map(),
|
|
35
|
+
// Subscription storage: Map<topic, Map<subId, callback>> for O(1) exact match
|
|
36
|
+
subscriptions: new Map(),
|
|
73
37
|
users: 0,
|
|
74
38
|
subscriptionCounter: 0
|
|
75
39
|
});
|
|
76
40
|
}
|
|
77
41
|
|
|
78
|
-
const instance =
|
|
42
|
+
const instance = sharedInstances.get(instanceKey);
|
|
79
43
|
instance.users++;
|
|
80
44
|
instance.emitter.setMaxListeners(100); // Allow many subscribers
|
|
81
45
|
|
|
46
|
+
// Initialize cache in global context if not exists
|
|
47
|
+
if (!globalContext.get(contextKey)) {
|
|
48
|
+
globalContext.set(contextKey, {});
|
|
49
|
+
}
|
|
50
|
+
|
|
82
51
|
// TTL cleanup interval
|
|
83
52
|
let ttlInterval = null;
|
|
84
53
|
if (node.ttl > 0) {
|
|
85
54
|
ttlInterval = setInterval(() => {
|
|
86
55
|
const now = Date.now();
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
56
|
+
const cache = globalContext.get(contextKey) || {};
|
|
57
|
+
let changed = false;
|
|
58
|
+
for (const topic of Object.keys(cache)) {
|
|
59
|
+
if (now - cache[topic].ts > node.ttl) {
|
|
60
|
+
delete cache[topic];
|
|
61
|
+
changed = true;
|
|
90
62
|
}
|
|
91
63
|
}
|
|
64
|
+
if (changed) {
|
|
65
|
+
globalContext.set(contextKey, cache);
|
|
66
|
+
}
|
|
92
67
|
}, Math.min(node.ttl, 60000)); // Check at most every minute
|
|
93
68
|
}
|
|
94
69
|
|
|
@@ -105,14 +80,26 @@ module.exports = function(RED) {
|
|
|
105
80
|
metadata: metadata
|
|
106
81
|
};
|
|
107
82
|
|
|
108
|
-
|
|
83
|
+
const cache = globalContext.get(contextKey) || {};
|
|
84
|
+
cache[topic] = entry;
|
|
109
85
|
|
|
110
86
|
// Enforce max entries (LRU eviction - remove oldest)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
87
|
+
const keys = Object.keys(cache);
|
|
88
|
+
if (keys.length > node.maxEntries) {
|
|
89
|
+
// Find oldest entry
|
|
90
|
+
let oldestKey = keys[0];
|
|
91
|
+
let oldestTs = cache[oldestKey].ts;
|
|
92
|
+
for (const key of keys) {
|
|
93
|
+
if (cache[key].ts < oldestTs) {
|
|
94
|
+
oldestTs = cache[key].ts;
|
|
95
|
+
oldestKey = key;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
delete cache[oldestKey];
|
|
114
99
|
}
|
|
115
100
|
|
|
101
|
+
globalContext.set(contextKey, cache);
|
|
102
|
+
|
|
116
103
|
// Emit topic-specific update event
|
|
117
104
|
instance.emitter.emit('update', topic, entry);
|
|
118
105
|
};
|
|
@@ -123,52 +110,23 @@ module.exports = function(RED) {
|
|
|
123
110
|
* @returns {object|undefined} - The cached entry {value, ts, metadata} or undefined
|
|
124
111
|
*/
|
|
125
112
|
node.getValue = function(topic) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Get all values matching a pattern
|
|
131
|
-
* @param {string} pattern - Pattern with * and ? wildcards
|
|
132
|
-
* @returns {Map} - Map of matching topic -> entry
|
|
133
|
-
*/
|
|
134
|
-
node.getMatching = function(pattern) {
|
|
135
|
-
const regex = patternToRegex(pattern);
|
|
136
|
-
const results = new Map();
|
|
137
|
-
|
|
138
|
-
for (const [topic, entry] of instance.cache) {
|
|
139
|
-
if (regex.test(topic)) {
|
|
140
|
-
results.set(topic, entry);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return results;
|
|
113
|
+
const cache = globalContext.get(contextKey) || {};
|
|
114
|
+
return cache[topic];
|
|
145
115
|
};
|
|
146
116
|
|
|
147
117
|
/**
|
|
148
|
-
* Subscribe to updates
|
|
149
|
-
*
|
|
150
|
-
* @param {
|
|
151
|
-
* @param {Function} callback - Called with (topic, entry) on match
|
|
118
|
+
* Subscribe to updates for a specific topic
|
|
119
|
+
* @param {string} topic - The exact topic to subscribe to
|
|
120
|
+
* @param {Function} callback - Called with (topic, entry) on update
|
|
152
121
|
* @returns {string} - Subscription ID for unsubscribe
|
|
153
122
|
*/
|
|
154
|
-
node.subscribe = function(
|
|
123
|
+
node.subscribe = function(topic, callback) {
|
|
155
124
|
const subId = `sub_${++instance.subscriptionCounter}`;
|
|
156
125
|
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
const regex = patternToRegex(pattern);
|
|
160
|
-
instance.wildcardSubscriptions.set(subId, {
|
|
161
|
-
pattern: pattern,
|
|
162
|
-
regex: regex,
|
|
163
|
-
callback: callback
|
|
164
|
-
});
|
|
165
|
-
} else {
|
|
166
|
-
// Exact match - store in topic-indexed map for O(1) lookup
|
|
167
|
-
if (!instance.exactSubscriptions.has(pattern)) {
|
|
168
|
-
instance.exactSubscriptions.set(pattern, new Map());
|
|
169
|
-
}
|
|
170
|
-
instance.exactSubscriptions.get(pattern).set(subId, callback);
|
|
126
|
+
if (!instance.subscriptions.has(topic)) {
|
|
127
|
+
instance.subscriptions.set(topic, new Map());
|
|
171
128
|
}
|
|
129
|
+
instance.subscriptions.get(topic).set(subId, callback);
|
|
172
130
|
|
|
173
131
|
return subId;
|
|
174
132
|
};
|
|
@@ -178,17 +136,11 @@ module.exports = function(RED) {
|
|
|
178
136
|
* @param {string} subscriptionId - The subscription ID to remove
|
|
179
137
|
*/
|
|
180
138
|
node.unsubscribe = function(subscriptionId) {
|
|
181
|
-
|
|
182
|
-
if (instance.wildcardSubscriptions.delete(subscriptionId)) {
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Search exact subscriptions
|
|
187
|
-
for (const [topic, subs] of instance.exactSubscriptions) {
|
|
139
|
+
for (const [topic, subs] of instance.subscriptions) {
|
|
188
140
|
if (subs.delete(subscriptionId)) {
|
|
189
141
|
// Clean up empty topic maps
|
|
190
142
|
if (subs.size === 0) {
|
|
191
|
-
instance.
|
|
143
|
+
instance.subscriptions.delete(topic);
|
|
192
144
|
}
|
|
193
145
|
return;
|
|
194
146
|
}
|
|
@@ -200,7 +152,8 @@ module.exports = function(RED) {
|
|
|
200
152
|
* @returns {string[]} - Array of all topic keys
|
|
201
153
|
*/
|
|
202
154
|
node.getTopics = function() {
|
|
203
|
-
|
|
155
|
+
const cache = globalContext.get(contextKey) || {};
|
|
156
|
+
return Object.keys(cache);
|
|
204
157
|
};
|
|
205
158
|
|
|
206
159
|
/**
|
|
@@ -208,23 +161,22 @@ module.exports = function(RED) {
|
|
|
208
161
|
* @returns {number} - Cache size
|
|
209
162
|
*/
|
|
210
163
|
node.size = function() {
|
|
211
|
-
|
|
164
|
+
const cache = globalContext.get(contextKey) || {};
|
|
165
|
+
return Object.keys(cache).length;
|
|
212
166
|
};
|
|
213
167
|
|
|
214
168
|
/**
|
|
215
169
|
* Clear all entries from cache
|
|
216
170
|
*/
|
|
217
171
|
node.clear = function() {
|
|
218
|
-
|
|
172
|
+
globalContext.set(contextKey, {});
|
|
219
173
|
};
|
|
220
174
|
|
|
221
|
-
// Internal: dispatch updates to matching subscriptions
|
|
222
|
-
// Optimized: O(1) for exact matches, O(w) for wildcard patterns
|
|
175
|
+
// Internal: dispatch updates to matching subscriptions (O(1) lookup)
|
|
223
176
|
const updateHandler = (topic, entry) => {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
for (const [subId, callback] of exactSubs) {
|
|
177
|
+
const subs = instance.subscriptions.get(topic);
|
|
178
|
+
if (subs) {
|
|
179
|
+
for (const [subId, callback] of subs) {
|
|
228
180
|
try {
|
|
229
181
|
callback(topic, entry);
|
|
230
182
|
} catch (err) {
|
|
@@ -232,17 +184,6 @@ module.exports = function(RED) {
|
|
|
232
184
|
}
|
|
233
185
|
}
|
|
234
186
|
}
|
|
235
|
-
|
|
236
|
-
// Second: iterate only wildcard subscriptions (typically fewer)
|
|
237
|
-
for (const [subId, sub] of instance.wildcardSubscriptions) {
|
|
238
|
-
if (sub.regex.test(topic)) {
|
|
239
|
-
try {
|
|
240
|
-
sub.callback(topic, entry);
|
|
241
|
-
} catch (err) {
|
|
242
|
-
RED.log.error(`[event-cache] Subscription callback error: ${err.message}`);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
187
|
};
|
|
247
188
|
instance.emitter.on('update', updateHandler);
|
|
248
189
|
|
|
@@ -254,11 +195,10 @@ module.exports = function(RED) {
|
|
|
254
195
|
|
|
255
196
|
instance.users--;
|
|
256
197
|
if (instance.users <= 0) {
|
|
257
|
-
|
|
258
|
-
instance.
|
|
259
|
-
instance.wildcardSubscriptions.clear();
|
|
198
|
+
// Don't clear the context cache - let it persist
|
|
199
|
+
instance.subscriptions.clear();
|
|
260
200
|
instance.emitter.removeAllListeners();
|
|
261
|
-
|
|
201
|
+
sharedInstances.delete(instanceKey);
|
|
262
202
|
}
|
|
263
203
|
done();
|
|
264
204
|
});
|
|
@@ -281,11 +221,11 @@ module.exports = function(RED) {
|
|
|
281
221
|
RED.httpAdmin.get("/event-cache/:id/stats", function(req, res) {
|
|
282
222
|
const node = RED.nodes.getNode(req.params.id);
|
|
283
223
|
if (node) {
|
|
284
|
-
const instance =
|
|
285
|
-
let
|
|
224
|
+
const instance = sharedInstances.get(node.id);
|
|
225
|
+
let subCount = 0;
|
|
286
226
|
if (instance) {
|
|
287
|
-
for (const subs of instance.
|
|
288
|
-
|
|
227
|
+
for (const subs of instance.subscriptions.values()) {
|
|
228
|
+
subCount += subs.size;
|
|
289
229
|
}
|
|
290
230
|
}
|
|
291
231
|
res.json({
|
|
@@ -294,9 +234,8 @@ module.exports = function(RED) {
|
|
|
294
234
|
maxEntries: node.maxEntries,
|
|
295
235
|
ttl: node.ttl,
|
|
296
236
|
subscriptions: {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
exactTopics: instance ? instance.exactSubscriptions.size : 0
|
|
237
|
+
count: subCount,
|
|
238
|
+
topics: instance ? instance.subscriptions.size : 0
|
|
300
239
|
}
|
|
301
240
|
});
|
|
302
241
|
} else {
|
|
@@ -317,11 +256,17 @@ module.exports = function(RED) {
|
|
|
317
256
|
// HTTP Admin endpoint to get topics from all caches
|
|
318
257
|
RED.httpAdmin.get("/event-cache/topics/all", function(req, res) {
|
|
319
258
|
const allTopics = new Set();
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
259
|
+
// Get all event-cache nodes and collect their topics
|
|
260
|
+
RED.nodes.eachNode(function(n) {
|
|
261
|
+
if (n.type === 'event-cache') {
|
|
262
|
+
const cacheNode = RED.nodes.getNode(n.id);
|
|
263
|
+
if (cacheNode && cacheNode.getTopics) {
|
|
264
|
+
for (const topic of cacheNode.getTopics()) {
|
|
265
|
+
allTopics.add(topic);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
323
268
|
}
|
|
324
|
-
}
|
|
269
|
+
});
|
|
325
270
|
res.json(Array.from(allTopics).sort());
|
|
326
271
|
});
|
|
327
272
|
};
|