node-red-contrib-join-wait 0.5.3 → 0.6.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/CHANGELOG.md +181 -0
- package/README.md +190 -1192
- package/examples/01-quickstart.json +96 -0
- package/examples/02-correlation.json +139 -0
- package/examples/03-reset.json +103 -0
- package/examples/04-regex.json +94 -0
- package/examples/05-exact-order.json +103 -0
- package/join-wait.html +481 -185
- package/join-wait.js +352 -361
- package/lib/config.js +26 -0
- package/lib/matcher.js +114 -0
- package/lib/persist.js +60 -0
- package/lib/store.js +64 -0
- package/package.json +91 -58
- package/.eslintrc.js +0 -24
- package/.github/FUNDING.yml +0 -12
- package/.nycrc.json +0 -11
- package/.prettierrc +0 -6
- package/.spelling +0 -20
- package/.travis.yml +0 -20
- package/docs/_config.yml +0 -1
- package/docs/example1.png +0 -0
- package/docs/example2.png +0 -0
- package/docs/example3.png +0 -0
- package/docs/example4.png +0 -0
- package/docs/example5.png +0 -0
- package/docs/example6.png +0 -0
- package/docs/venmo.png +0 -0
- package/test/flows.js +0 -33
- package/test/test_spec.js +0 -1411
package/join-wait.html
CHANGED
|
@@ -1,34 +1,33 @@
|
|
|
1
1
|
<script type="text/x-red" data-template-name="join-wait">
|
|
2
2
|
<div class="form-row">
|
|
3
|
-
<label for="node-input-
|
|
4
|
-
<input type="text" id="node-input-
|
|
5
|
-
<input type="hidden" id="node-input-pathTopicType">
|
|
6
|
-
</div>
|
|
7
|
-
<div class="form-row">
|
|
8
|
-
<label for="node-input-paths"><i class="fa fa-code-fork"></i> Paths (Wait)</label>
|
|
9
|
-
<input type="text" id="node-input-paths" placeholder="["path_1", "path_2"]">
|
|
3
|
+
<label for="node-input-name" title="Display name for this node on the canvas."><i class="fa fa-tag"></i> Name</label>
|
|
4
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
10
5
|
</div>
|
|
6
|
+
|
|
11
7
|
<div class="form-row">
|
|
12
|
-
<label for="node-input-
|
|
13
|
-
<input type="text" id="node-input-
|
|
8
|
+
<label for="node-input-pathTopic" title="Message property whose value identifies the incoming path."><i class="fa fa-bookmark-o"></i> Path field</label>
|
|
9
|
+
<input type="text" id="node-input-pathTopic" placeholder="topic">
|
|
10
|
+
<input type="hidden" id="node-input-pathTopicType">
|
|
14
11
|
</div>
|
|
15
|
-
<div class="form-
|
|
16
|
-
|
|
17
|
-
<
|
|
18
|
-
|
|
12
|
+
<div class="form-tips" style="margin-bottom:12px">
|
|
13
|
+
The message property whose value identifies an incoming path —
|
|
14
|
+
e.g. <code>msg.topic</code> or <code>msg.paths</code>. May be a string
|
|
15
|
+
or an object with multiple keys.
|
|
19
16
|
</div>
|
|
17
|
+
|
|
20
18
|
<div class="form-row">
|
|
21
|
-
<label for="
|
|
22
|
-
<
|
|
23
|
-
<input type="checkbox" id="node-input-warnUnmatched" style="display:inline-block; width:22px; vertical-align:baseline;">Warn on unmatched path</label>
|
|
19
|
+
<label title="Path names to wait for; repeat an entry to require it n times."><i class="fa fa-code-fork"></i> Wait paths</label>
|
|
20
|
+
<ol id="node-input-paths-container"></ol>
|
|
24
21
|
</div>
|
|
25
|
-
<div class="form-
|
|
26
|
-
|
|
27
|
-
<
|
|
28
|
-
|
|
22
|
+
<div class="form-tips" style="margin-bottom:6px">
|
|
23
|
+
The set of paths whose arrival within the time window emits a
|
|
24
|
+
merged success output. Press <kbd>Enter</kbd> in a row to add the
|
|
25
|
+
next one. Repeat an entry to require n-of-the-same.
|
|
29
26
|
</div>
|
|
27
|
+
<div class="form-tips" id="join-wait-output-preview" style="margin-bottom:12px; font-family:monospace; color:#888"></div>
|
|
28
|
+
|
|
30
29
|
<div class="form-row">
|
|
31
|
-
<label for="node-input-timeout"><i class="fa fa-clock-o"></i> Timeout</label>
|
|
30
|
+
<label for="node-input-timeout" title="How long all wait paths have to arrive before the queue is expired."><i class="fa fa-clock-o"></i> Timeout</label>
|
|
32
31
|
<input type="text" id="node-input-timeout" style="text-align:end; width:100px !important" placeholder="15">
|
|
33
32
|
<select id="node-input-timeoutUnits" style="width:200px !important">
|
|
34
33
|
<option value="1">Milliseconds</option>
|
|
@@ -38,224 +37,521 @@
|
|
|
38
37
|
<option value="86400000">Days</option>
|
|
39
38
|
</select>
|
|
40
39
|
</div>
|
|
40
|
+
<div class="form-tips" id="join-wait-timeout-hint" style="margin-bottom:12px; color:#b07b00; display:none"></div>
|
|
41
|
+
|
|
41
42
|
<div class="form-row">
|
|
42
|
-
<label for="node-input-exactOrder"><i class="fa fa-sort"></i>
|
|
43
|
+
<label for="node-input-exactOrder" title="Any order (default) or strict sequence."><i class="fa fa-sort"></i> Match order</label>
|
|
43
44
|
<select id="node-input-exactOrder" style="width:70%; margin-right:5px;">
|
|
44
45
|
<option value="false">Any order</option>
|
|
45
|
-
<option value="true">Exact order
|
|
46
|
+
<option value="true">Exact order</option>
|
|
46
47
|
</select>
|
|
47
48
|
</div>
|
|
49
|
+
|
|
50
|
+
<div class="form-row">
|
|
51
|
+
<label for="node-input-correlationTopic" title="Optional. Property whose value groups related messages (e.g. msg._msgid)."><i class="fa fa-bookmark-o"></i> Group by</label>
|
|
52
|
+
<input type="text" id="node-input-correlationTopic" placeholder="(none)">
|
|
53
|
+
<input type="hidden" id="node-input-correlationTopicType">
|
|
54
|
+
</div>
|
|
55
|
+
<div class="form-tips" style="margin-bottom:12px">
|
|
56
|
+
Optional. Group messages so unrelated streams don't merge —
|
|
57
|
+
e.g. <code>msg._msgid</code> when waiting on a single split flow.
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="form-row" style="margin-bottom:14px">
|
|
61
|
+
<label></label>
|
|
62
|
+
<button type="button" id="join-wait-open-examples" class="red-ui-button">
|
|
63
|
+
<i class="fa fa-folder-open-o"></i> Open example flows
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<details style="margin-top:14px">
|
|
68
|
+
<summary style="cursor:pointer; font-weight:bold; padding:6px 0">Advanced</summary>
|
|
69
|
+
|
|
70
|
+
<div class="form-row" style="margin-top:10px">
|
|
71
|
+
<label title="Paths that immediately drain the queue to the expired output."><i class="fa fa-times-circle-o"></i> Reset paths</label>
|
|
72
|
+
<ol id="node-input-pathsToExpire-container"></ol>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="form-tips" style="margin-bottom:12px">
|
|
75
|
+
Optional. Any of these paths immediately drains the queue (with the
|
|
76
|
+
drained items going to the second output). Each entry must be unique.
|
|
77
|
+
</div>
|
|
78
|
+
|
|
48
79
|
<div class="form-row">
|
|
49
|
-
<label for="node-input-firstMsg"><i class="fa fa-comments-o"></i>
|
|
80
|
+
<label for="node-input-firstMsg" title="Use the first or last message in the matched window as the base of the merged output."><i class="fa fa-comments-o"></i> Output base</label>
|
|
50
81
|
<select id="node-input-firstMsg" style="width:70%; margin-right:5px;">
|
|
51
|
-
<option value="true">First received</option>
|
|
52
|
-
<option value="false">Last received</option>
|
|
82
|
+
<option value="true">First received message</option>
|
|
83
|
+
<option value="false">Last received message</option>
|
|
53
84
|
</select>
|
|
54
85
|
</div>
|
|
86
|
+
|
|
55
87
|
<div class="form-row">
|
|
56
|
-
<label for="node-input-mapPayload"><i class="fa fa-arrow-right"></i>
|
|
88
|
+
<label for="node-input-mapPayload" title="Keep the original Path-field values, or overwrite each with that message's payload."><i class="fa fa-arrow-right"></i> Merge values</label>
|
|
57
89
|
<select id="node-input-mapPayload" style="width:70%; margin-right:5px;">
|
|
58
|
-
<option value="false">Original
|
|
59
|
-
<option value="true">
|
|
90
|
+
<option value="false">Original Path field values</option>
|
|
91
|
+
<option value="true">Each msg.payload</option>
|
|
60
92
|
</select>
|
|
61
93
|
</div>
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<label for="node-input-
|
|
65
|
-
<input type="checkbox" id="node-input-
|
|
94
|
+
|
|
95
|
+
<div class="form-row" title="Treat each entry in Wait paths and Reset paths as a regular expression.">
|
|
96
|
+
<label for="node-input-useRegex"></label>
|
|
97
|
+
<input type="checkbox" id="node-input-useRegex" style="display:inline-block; width:22px; vertical-align:baseline; margin-right:6px;">
|
|
98
|
+
<label for="node-input-useRegex" style="width:auto">Treat path entries as regular expressions</label>
|
|
66
99
|
</div>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
<label for="node-input-
|
|
70
|
-
<input type="checkbox" id="node-input-
|
|
100
|
+
|
|
101
|
+
<div class="form-row" title="Log a warning when a message arrives with a path that isn't in either list.">
|
|
102
|
+
<label for="node-input-warnUnmatched"></label>
|
|
103
|
+
<input type="checkbox" id="node-input-warnUnmatched" style="display:inline-block; width:22px; vertical-align:baseline; margin-right:6px;">
|
|
104
|
+
<label for="node-input-warnUnmatched" style="width:auto">Log a warning for unmatched paths</label>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div class="form-row" title="Disable the msg.complete short-circuit.">
|
|
108
|
+
<label for="node-input-disableComplete"></label>
|
|
109
|
+
<input type="checkbox" id="node-input-disableComplete" style="display:inline-block; width:22px; vertical-align:baseline; margin-right:6px;">
|
|
110
|
+
<label for="node-input-disableComplete" style="width:auto">Ignore <code>msg.complete</code></label>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div class="form-row" title="Save the queue to the context store on close so it survives redeploys (and full restarts when a persistent store is configured).">
|
|
114
|
+
<label for="node-input-persistOnRestart"></label>
|
|
115
|
+
<input type="checkbox" id="node-input-persistOnRestart" style="display:inline-block; width:22px; vertical-align:baseline; margin-right:6px;">
|
|
116
|
+
<label for="node-input-persistOnRestart" style="width:auto">Preserve queue across deploys / restarts</label>
|
|
71
117
|
</div>
|
|
118
|
+
|
|
72
119
|
<div class="form-row">
|
|
73
|
-
<label for="node-input-
|
|
74
|
-
<
|
|
120
|
+
<label for="node-input-persistStore" title="Override the context store used to persist the queue."><i class="fa fa-database"></i> Persist store</label>
|
|
121
|
+
<select id="node-input-persistStore" style="width:70%; margin-right:5px;">
|
|
122
|
+
<option value="">(default context store)</option>
|
|
123
|
+
</select>
|
|
75
124
|
</div>
|
|
76
|
-
<div class="form-
|
|
77
|
-
|
|
125
|
+
<div class="form-tips" style="margin-bottom:12px">
|
|
126
|
+
Override the context store this node uses. Leave on default — set
|
|
127
|
+
the default to a persistent store (e.g. <code>localfilesystem</code>)
|
|
128
|
+
in <code>settings.js</code> and every join-wait node automatically
|
|
129
|
+
survives full restarts.
|
|
78
130
|
</div>
|
|
131
|
+
</details>
|
|
79
132
|
</script>
|
|
80
133
|
|
|
81
134
|
<script type="text/javascript">
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
paths: {
|
|
90
|
-
value: '',
|
|
91
|
-
validate: function (v) {
|
|
92
|
-
if (v === '') {
|
|
93
|
-
return true;
|
|
94
|
-
}
|
|
135
|
+
(function () {
|
|
136
|
+
function arrayValidator(allowEmpty, requireUnique) {
|
|
137
|
+
return function (v) {
|
|
138
|
+
// Accept legacy JSON-string format too, for back-compat with old flows.
|
|
139
|
+
var arr = v;
|
|
140
|
+
if (typeof v === 'string') {
|
|
141
|
+
if (v === '') return allowEmpty;
|
|
95
142
|
try {
|
|
96
|
-
|
|
97
|
-
return Array.isArray(json) && json.length > 0;
|
|
143
|
+
arr = JSON.parse(v);
|
|
98
144
|
} catch (err) {
|
|
99
|
-
|
|
145
|
+
return false;
|
|
100
146
|
}
|
|
147
|
+
}
|
|
148
|
+
if (!Array.isArray(arr)) return false;
|
|
149
|
+
if (arr.length === 0) return allowEmpty;
|
|
150
|
+
if (
|
|
151
|
+
!arr.every(function (s) {
|
|
152
|
+
return typeof s === 'string' && s.length > 0;
|
|
153
|
+
})
|
|
154
|
+
)
|
|
101
155
|
return false;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (v === '') {
|
|
108
|
-
return true;
|
|
156
|
+
if (requireUnique) {
|
|
157
|
+
var seen = Object.create(null);
|
|
158
|
+
for (var i = 0; i < arr.length; i++) {
|
|
159
|
+
if (seen[arr[i]]) return false;
|
|
160
|
+
seen[arr[i]] = true;
|
|
109
161
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function correlationValidator(v) {
|
|
168
|
+
// Empty / undefined / non-jsonata: nothing to validate here.
|
|
169
|
+
if (this.correlationTopicType !== 'jsonata' || !v) return true;
|
|
170
|
+
// jsonata is exposed as a global by the Node-RED editor.
|
|
171
|
+
if (typeof jsonata !== 'function') return true;
|
|
172
|
+
try {
|
|
173
|
+
jsonata(v);
|
|
174
|
+
return true;
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isValidRegex(s) {
|
|
181
|
+
try {
|
|
182
|
+
new RegExp(s);
|
|
183
|
+
return true;
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function setRowValid(input, ok, message) {
|
|
190
|
+
input.css('border-color', ok ? '' : '#d9534f');
|
|
191
|
+
input.attr('title', ok ? '' : message || '');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function validateRow(input) {
|
|
195
|
+
var $useRegex = $('#node-input-useRegex');
|
|
196
|
+
var v = String(input.val() || '');
|
|
197
|
+
if (v === '') {
|
|
198
|
+
setRowValid(input, false, 'path name cannot be empty');
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
if ($useRegex.is(':checked') && !isValidRegex(v)) {
|
|
202
|
+
setRowValid(input, false, 'invalid regular expression');
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
setRowValid(input, true);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function initEditableList(containerId, items, opts) {
|
|
210
|
+
var $container = $('#' + containerId);
|
|
211
|
+
$container.css({ 'min-height': '100px' });
|
|
212
|
+
var placeholder = (opts && opts.placeholder) || 'e.g. sensor_1';
|
|
213
|
+
$container.editableList({
|
|
214
|
+
addItem: function (row, index, data) {
|
|
215
|
+
var value = (data && data.value) || '';
|
|
216
|
+
row.css({ overflow: 'hidden', whiteSpace: 'nowrap' });
|
|
217
|
+
var input = $('<input/>', {
|
|
218
|
+
type: 'text',
|
|
219
|
+
placeholder: placeholder,
|
|
220
|
+
style: 'width:100%',
|
|
221
|
+
}).val(value);
|
|
222
|
+
input.on('input change blur', function () {
|
|
223
|
+
validateRow(input);
|
|
224
|
+
if (opts && opts.onChange) opts.onChange();
|
|
225
|
+
});
|
|
226
|
+
// Pressing Enter in a row adds the next one.
|
|
227
|
+
input.on('keydown', function (e) {
|
|
228
|
+
if (e.key === 'Enter' || e.keyCode === 13) {
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
$container.editableList('addItem', { value: '' });
|
|
231
|
+
// Focus the newly-added row.
|
|
232
|
+
setTimeout(function () {
|
|
233
|
+
$container.editableList('items').last().find('input').trigger('focus');
|
|
234
|
+
}, 0);
|
|
116
235
|
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
236
|
+
});
|
|
237
|
+
row.append(input);
|
|
238
|
+
validateRow(input);
|
|
239
|
+
if (opts && opts.onChange) opts.onChange();
|
|
121
240
|
},
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
value: false,
|
|
125
|
-
},
|
|
126
|
-
warnUnmatched: {
|
|
127
|
-
value: true,
|
|
128
|
-
},
|
|
129
|
-
pathTopic: {
|
|
130
|
-
value: 'topic',
|
|
131
|
-
},
|
|
132
|
-
pathTopicType: {
|
|
133
|
-
value: 'msg',
|
|
134
|
-
},
|
|
135
|
-
correlationTopic: {
|
|
136
|
-
value: '',
|
|
137
|
-
},
|
|
138
|
-
correlationTopicType: {
|
|
139
|
-
value: 'undefined',
|
|
140
|
-
},
|
|
141
|
-
timeout: {
|
|
142
|
-
value: 15000,
|
|
143
|
-
required: true,
|
|
144
|
-
validate: function (v) {
|
|
145
|
-
return (Number(v) || 0) > 0;
|
|
241
|
+
removeItem: function () {
|
|
242
|
+
if (opts && opts.onChange) opts.onChange();
|
|
146
243
|
},
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
validate: RED.validators.number(),
|
|
151
|
-
},
|
|
152
|
-
exactOrder: {
|
|
153
|
-
value: 'false',
|
|
154
|
-
required: true,
|
|
155
|
-
},
|
|
156
|
-
firstMsg: {
|
|
157
|
-
value: 'true',
|
|
158
|
-
required: true,
|
|
159
|
-
},
|
|
160
|
-
mapPayload: {
|
|
161
|
-
value: 'false',
|
|
162
|
-
required: true,
|
|
163
|
-
},
|
|
164
|
-
disableComplete: {
|
|
165
|
-
value: false,
|
|
166
|
-
},
|
|
167
|
-
persistOnRestart: {
|
|
168
|
-
value: false,
|
|
169
|
-
},
|
|
170
|
-
},
|
|
171
|
-
inputs: 1,
|
|
172
|
-
outputs: 2,
|
|
173
|
-
outputLabels: ['success', 'expired'],
|
|
174
|
-
icon: 'node-red-contrib-join-wait.png',
|
|
175
|
-
label: function () {
|
|
176
|
-
return this.name || 'join-wait';
|
|
177
|
-
},
|
|
178
|
-
labelStyle: function () {
|
|
179
|
-
return this.name ? 'node_label_italic' : '';
|
|
180
|
-
},
|
|
181
|
-
oneditprepare: function () {
|
|
182
|
-
var node = this;
|
|
183
|
-
var previousValueType = {
|
|
184
|
-
value: 'prev',
|
|
185
|
-
label: this._('join-wait.previous'),
|
|
186
|
-
hasValue: false,
|
|
187
|
-
};
|
|
188
|
-
$('#node-input-pathTopic').typedInput({
|
|
189
|
-
default: this.pathTopicType || 'msg',
|
|
190
|
-
typeField: $('#node-input-pathTopicType'),
|
|
191
|
-
types: ['msg', 'flow', 'global'],
|
|
244
|
+
removable: true,
|
|
245
|
+
sortable: true,
|
|
246
|
+
height: 'auto',
|
|
192
247
|
});
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
typeField: $('#node-input-correlationTopicType'),
|
|
196
|
-
types: [
|
|
197
|
-
{ value: 'undefined', label: RED._('common.type.undefined'), hasValue: false },
|
|
198
|
-
'msg',
|
|
199
|
-
'flow',
|
|
200
|
-
'global',
|
|
201
|
-
'jsonata',
|
|
202
|
-
],
|
|
248
|
+
(items || []).forEach(function (v) {
|
|
249
|
+
$container.editableList('addItem', { value: v });
|
|
203
250
|
});
|
|
204
|
-
|
|
205
|
-
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Re-validate every editableList row when the regex toggle flips.
|
|
254
|
+
function rebindRegexValidation() {
|
|
255
|
+
$('#node-input-useRegex').on('change', function () {
|
|
256
|
+
$(
|
|
257
|
+
'#node-input-paths-container input[type=text], ' +
|
|
258
|
+
'#node-input-pathsToExpire-container input[type=text]',
|
|
259
|
+
).each(function () {
|
|
260
|
+
validateRow($(this));
|
|
261
|
+
});
|
|
206
262
|
});
|
|
207
|
-
}
|
|
208
|
-
});
|
|
209
|
-
</script>
|
|
263
|
+
}
|
|
210
264
|
|
|
211
|
-
|
|
212
|
-
|
|
265
|
+
function readEditableList(containerId) {
|
|
266
|
+
var values = [];
|
|
267
|
+
$('#' + containerId)
|
|
268
|
+
.editableList('items')
|
|
269
|
+
.each(function () {
|
|
270
|
+
var v = $(this).find('input').val();
|
|
271
|
+
if (v != null && v !== '') values.push(v);
|
|
272
|
+
});
|
|
273
|
+
return values;
|
|
274
|
+
}
|
|
213
275
|
|
|
214
|
-
|
|
276
|
+
// Coerce legacy string-array values into a plain array for editing.
|
|
277
|
+
function toArray(v) {
|
|
278
|
+
if (Array.isArray(v)) return v;
|
|
279
|
+
if (typeof v === 'string' && v !== '') {
|
|
280
|
+
try {
|
|
281
|
+
var parsed = JSON.parse(v);
|
|
282
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
283
|
+
} catch (err) {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
215
289
|
|
|
216
|
-
|
|
290
|
+
// Render a tiny preview of what msg.<pathField> looks like on the
|
|
291
|
+
// success output, derived from the current Wait paths list.
|
|
292
|
+
function updateOutputPreview() {
|
|
293
|
+
var paths = readEditableList('node-input-paths-container');
|
|
294
|
+
var unique = [];
|
|
295
|
+
paths.forEach(function (p) {
|
|
296
|
+
if (unique.indexOf(p) === -1) unique.push(p);
|
|
297
|
+
});
|
|
298
|
+
var pathField = $('#node-input-pathTopic').val() || 'topic';
|
|
299
|
+
var $tip = $('#join-wait-output-preview');
|
|
300
|
+
if (unique.length === 0) {
|
|
301
|
+
$tip.text('');
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
var entries = unique.slice(0, 4).map(function (p) {
|
|
305
|
+
return p + ': …';
|
|
306
|
+
});
|
|
307
|
+
if (unique.length > 4) entries.push('…');
|
|
308
|
+
$tip.text('→ msg.' + pathField + ' = { ' + entries.join(', ') + ' }');
|
|
309
|
+
}
|
|
217
310
|
|
|
218
|
-
|
|
311
|
+
// Warn when the resolved timeout is impractically short (the README
|
|
312
|
+
// recommends padding ~5–10 ms for evaluation overhead).
|
|
313
|
+
function updateTimeoutHint() {
|
|
314
|
+
var t = Number($('#node-input-timeout').val()) || 0;
|
|
315
|
+
var u = Number($('#node-input-timeoutUnits').val()) || 1;
|
|
316
|
+
var ms = t * u;
|
|
317
|
+
var $hint = $('#join-wait-timeout-hint');
|
|
318
|
+
if (ms > 0 && ms < 50) {
|
|
319
|
+
$hint.text('Very short — pad ~10 ms to leave room for evaluation overhead.').show();
|
|
320
|
+
} else {
|
|
321
|
+
$hint.hide();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
219
324
|
|
|
220
|
-
|
|
325
|
+
// Populate the Persist store <select> from the runtime via our
|
|
326
|
+
// /join-wait/stores admin route. Falls back gracefully if the route
|
|
327
|
+
// is unreachable or returns nothing useful.
|
|
328
|
+
function populatePersistStores(currentValue) {
|
|
329
|
+
var $sel = $('#node-input-persistStore');
|
|
330
|
+
$.getJSON('join-wait/stores')
|
|
331
|
+
.done(function (stores) {
|
|
332
|
+
if (!Array.isArray(stores) || stores.length === 0) return;
|
|
333
|
+
stores.forEach(function (s) {
|
|
334
|
+
if (s.name === 'default') return; // already represented as the blank option
|
|
335
|
+
var label = s.module ? s.name + ' (' + s.module + ')' : s.name;
|
|
336
|
+
$('<option/>').val(s.name).text(label).appendTo($sel);
|
|
337
|
+
});
|
|
338
|
+
// If the saved value isn't in the list, append it so we
|
|
339
|
+
// don't silently drop a user-provided override.
|
|
340
|
+
if (currentValue && !$sel.find('option[value="' + currentValue + '"]').length) {
|
|
341
|
+
$('<option/>')
|
|
342
|
+
.val(currentValue)
|
|
343
|
+
.text(currentValue + ' (not configured)')
|
|
344
|
+
.appendTo($sel);
|
|
345
|
+
}
|
|
346
|
+
$sel.val(currentValue || '');
|
|
347
|
+
})
|
|
348
|
+
.fail(function () {
|
|
349
|
+
// Admin route not reachable — leave the select with just
|
|
350
|
+
// its default option.
|
|
351
|
+
});
|
|
352
|
+
}
|
|
221
353
|
|
|
222
|
-
|
|
354
|
+
function openExamplesDialog() {
|
|
355
|
+
// The edit tray is on top of the editor, so the import tray
|
|
356
|
+
// can't surface beneath it. Close this tray first (which cancels
|
|
357
|
+
// the in-flight edit), then invoke the core import dialog so the
|
|
358
|
+
// user can pick Examples → join-wait.
|
|
359
|
+
var invoke = function () {
|
|
360
|
+
if (RED.actions && typeof RED.actions.invoke === 'function') {
|
|
361
|
+
RED.actions.invoke('core:show-import-dialog');
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
if (RED.tray && typeof RED.tray.close === 'function') {
|
|
365
|
+
RED.tray.close(invoke);
|
|
366
|
+
} else {
|
|
367
|
+
invoke();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
223
370
|
|
|
224
|
-
|
|
371
|
+
RED.nodes.registerType('join-wait', {
|
|
372
|
+
category: 'function',
|
|
373
|
+
color: '#8baac7',
|
|
374
|
+
defaults: {
|
|
375
|
+
name: { value: '' },
|
|
376
|
+
paths: { value: [], validate: arrayValidator(false, false) },
|
|
377
|
+
pathsToExpire: { value: [], validate: arrayValidator(true, true) },
|
|
378
|
+
useRegex: { value: false },
|
|
379
|
+
warnUnmatched: { value: true },
|
|
380
|
+
pathTopic: { value: 'topic', required: true },
|
|
381
|
+
pathTopicType: { value: 'msg' },
|
|
382
|
+
correlationTopic: { value: '', validate: correlationValidator },
|
|
383
|
+
correlationTopicType: { value: 'undefined' },
|
|
384
|
+
timeout: {
|
|
385
|
+
value: 15000,
|
|
386
|
+
required: true,
|
|
387
|
+
validate: function (v) {
|
|
388
|
+
return (Number(v) || 0) > 0;
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
timeoutUnits: { value: 1, validate: RED.validators.number() },
|
|
392
|
+
exactOrder: { value: 'false', required: true },
|
|
393
|
+
firstMsg: { value: 'true', required: true },
|
|
394
|
+
mapPayload: { value: 'false', required: true },
|
|
395
|
+
disableComplete: { value: false },
|
|
396
|
+
// Defaults to true so the queue survives a redeploy out of
|
|
397
|
+
// the box (uses the default in-memory context store).
|
|
398
|
+
// For full Node-RED restart persistence the user still needs
|
|
399
|
+
// to point Persist store at a configured persistent store.
|
|
400
|
+
persistOnRestart: { value: true },
|
|
401
|
+
persistStore: { value: '' },
|
|
402
|
+
},
|
|
403
|
+
inputs: 1,
|
|
404
|
+
outputs: 2,
|
|
405
|
+
outputLabels: ['success', 'expired'],
|
|
406
|
+
icon: 'node-red-contrib-join-wait.png',
|
|
407
|
+
label: function () {
|
|
408
|
+
return this.name || 'join-wait';
|
|
409
|
+
},
|
|
410
|
+
labelStyle: function () {
|
|
411
|
+
return this.name ? 'node_label_italic' : '';
|
|
412
|
+
},
|
|
413
|
+
oneditprepare: function () {
|
|
414
|
+
$('#node-input-pathTopic').typedInput({
|
|
415
|
+
default: this.pathTopicType || 'msg',
|
|
416
|
+
typeField: $('#node-input-pathTopicType'),
|
|
417
|
+
types: ['msg', 'flow', 'global'],
|
|
418
|
+
});
|
|
419
|
+
$('#node-input-correlationTopic').typedInput({
|
|
420
|
+
default: this.correlationTopicType || 'undefined',
|
|
421
|
+
typeField: $('#node-input-correlationTopicType'),
|
|
422
|
+
types: [
|
|
423
|
+
{ value: 'undefined', label: RED._('common.type.undefined'), hasValue: false },
|
|
424
|
+
'msg',
|
|
425
|
+
'flow',
|
|
426
|
+
'global',
|
|
427
|
+
'jsonata',
|
|
428
|
+
],
|
|
429
|
+
});
|
|
430
|
+
$('#node-input-timeout').spinner({ min: 1 });
|
|
225
431
|
|
|
226
|
-
|
|
432
|
+
initEditableList('node-input-paths-container', toArray(this.paths), {
|
|
433
|
+
placeholder: 'e.g. sensor_1',
|
|
434
|
+
onChange: updateOutputPreview,
|
|
435
|
+
});
|
|
436
|
+
initEditableList('node-input-pathsToExpire-container', toArray(this.pathsToExpire), {
|
|
437
|
+
placeholder: 'e.g. abort',
|
|
438
|
+
});
|
|
439
|
+
rebindRegexValidation();
|
|
227
440
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
<p>2. As an object, set to any value (e.g., <code>msg.paths["path_1"] = {"example": "data"};</code> or <code>msg.paths["path_1"] = 42;</code>). If the object format is used, multiple paths can be specified. For example, <code>msg.paths = {"path_1": true, "path_2": true};</code> This can be useful if one flow needs to trigger multiple paths.</p>
|
|
441
|
+
$('#node-input-pathTopic').on('change input', updateOutputPreview);
|
|
442
|
+
updateOutputPreview();
|
|
231
443
|
|
|
232
|
-
|
|
444
|
+
$('#node-input-timeout, #node-input-timeoutUnits').on('change input', updateTimeoutHint);
|
|
445
|
+
updateTimeoutHint();
|
|
233
446
|
|
|
234
|
-
|
|
447
|
+
populatePersistStores(this.persistStore);
|
|
235
448
|
|
|
236
|
-
|
|
449
|
+
$('#join-wait-open-examples').on('click', openExamplesDialog);
|
|
450
|
+
},
|
|
451
|
+
oneditsave: function () {
|
|
452
|
+
this.paths = readEditableList('node-input-paths-container');
|
|
453
|
+
this.pathsToExpire = readEditableList('node-input-pathsToExpire-container');
|
|
454
|
+
},
|
|
455
|
+
oneditresize: function () {
|
|
456
|
+
$('#node-input-paths-container').css('min-height', '100px');
|
|
457
|
+
$('#node-input-pathsToExpire-container').css('min-height', '60px');
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
})();
|
|
461
|
+
</script>
|
|
462
|
+
|
|
463
|
+
<script type="text/x-red" data-help-name="join-wait">
|
|
464
|
+
<p>Waits for a set of related messages to arrive on different paths within
|
|
465
|
+
a time window, then emits a single merged message. Optionally treats some
|
|
466
|
+
paths as resets that drain the queue.</p>
|
|
467
|
+
|
|
468
|
+
<h3>Inputs</h3>
|
|
469
|
+
<dl class="message-properties">
|
|
470
|
+
<dt>payload<span class="property-type">any</span></dt>
|
|
471
|
+
<dd>Forwarded as-is (and optionally used to merge values — see "Merge values").</dd>
|
|
472
|
+
|
|
473
|
+
<dt class="optional">path field<span class="property-type">string | object</span></dt>
|
|
474
|
+
<dd>The message property named in the <b>Path field</b> setting (default <code>msg.topic</code>).
|
|
475
|
+
Either a single path name (<code>"path_1"</code>) or an object whose
|
|
476
|
+
keys are path names (<code>{path_1: ..., path_2: ...}</code>) when one
|
|
477
|
+
message represents multiple paths.</dd>
|
|
237
478
|
|
|
238
|
-
|
|
479
|
+
<dt class="optional">complete<span class="property-type">any</span></dt>
|
|
480
|
+
<dd>If present, the queue is evaluated for completion and then drained.
|
|
481
|
+
Disable via "Ignore <code>msg.complete</code>".</dd>
|
|
239
482
|
|
|
240
|
-
|
|
483
|
+
<dt class="optional">reset<span class="property-type">boolean</span></dt>
|
|
484
|
+
<dd>If <code>true</code>, the queue for the current group is silently
|
|
485
|
+
cleared (no output emitted). The other message fields are ignored.</dd>
|
|
241
486
|
|
|
242
|
-
|
|
487
|
+
<dt class="optional">pathsToWait<span class="property-type">string[]</span></dt>
|
|
488
|
+
<dd>Per-message override of "Wait paths" (one-shot — does not change
|
|
489
|
+
the node's stored config).</dd>
|
|
490
|
+
|
|
491
|
+
<dt class="optional">pathsToExpire<span class="property-type">string[]</span></dt>
|
|
492
|
+
<dd>Per-message override of "Reset paths" (one-shot).</dd>
|
|
493
|
+
|
|
494
|
+
<dt class="optional">useRegex<span class="property-type">boolean</span></dt>
|
|
495
|
+
<dd>Per-message override of the regex setting (one-shot).</dd>
|
|
496
|
+
</dl>
|
|
497
|
+
|
|
498
|
+
<h3>Outputs</h3>
|
|
499
|
+
<ol class="node-ports">
|
|
500
|
+
<li>Success — emitted when all "Wait paths" arrive within the timeout.
|
|
501
|
+
The merged data is attached on the <b>Path field</b> property.</li>
|
|
502
|
+
<li>Expired — drained messages from queues that timed out, hit a reset
|
|
503
|
+
path, or that <code>msg.complete</code> evaluated.</li>
|
|
504
|
+
</ol>
|
|
505
|
+
|
|
506
|
+
<h3>Details</h3>
|
|
243
507
|
|
|
244
|
-
<p>
|
|
508
|
+
<p><b>Path field</b> — the property whose value identifies the path. Can
|
|
509
|
+
be set as a string (one path) or an object (multiple paths from one
|
|
510
|
+
message). Defaults to <code>msg.topic</code>.</p>
|
|
245
511
|
|
|
246
|
-
<p>
|
|
512
|
+
<p><b>Wait paths</b> — list of paths to wait for. Repeating an entry means
|
|
513
|
+
"this path must arrive that many times". Configurable at runtime via
|
|
514
|
+
<code>msg.pathsToWait</code>.</p>
|
|
247
515
|
|
|
248
|
-
<p>
|
|
516
|
+
<p><b>Reset paths</b> — paths that immediately drain the queue. Each must
|
|
517
|
+
be unique. Configurable at runtime via <code>msg.pathsToExpire</code>.</p>
|
|
249
518
|
|
|
250
|
-
<p>
|
|
519
|
+
<p><b>Group by</b> — optional correlation property. When set, only
|
|
520
|
+
messages with the same value are joined together. Useful with
|
|
521
|
+
<code>msg._msgid</code> when waiting on a single split flow. Supports
|
|
522
|
+
<code>msg</code>, <code>flow</code>, <code>global</code>, and
|
|
523
|
+
<code>jsonata</code> property types.</p>
|
|
251
524
|
|
|
252
|
-
<p>
|
|
525
|
+
<p><b>Match order</b> — "Any order" (default) or "Exact order". In exact
|
|
526
|
+
mode, unexpected paths between expected ones are tolerated. To determine
|
|
527
|
+
the matching window, the timestamp of the <i>latest</i> required path is
|
|
528
|
+
used. For example, with wait paths <code>[path_1, path_2, path_3]</code>
|
|
529
|
+
and arrivals <code>[path_1, path_2, path_1*, path_2*, path_3*]</code>,
|
|
530
|
+
the starred entries form the match.</p>
|
|
253
531
|
|
|
254
|
-
<p>
|
|
532
|
+
<p><b>Output base</b> — whether to use the first or last received message
|
|
533
|
+
as the base for the merged output.</p>
|
|
255
534
|
|
|
256
|
-
<p>
|
|
535
|
+
<p><b>Merge values</b> — keep the original values from the path field, or
|
|
536
|
+
overwrite each with that message's <code>msg.payload</code>.</p>
|
|
257
537
|
|
|
258
|
-
<
|
|
538
|
+
<h3>Notes</h3>
|
|
259
539
|
|
|
260
|
-
<
|
|
540
|
+
<ul>
|
|
541
|
+
<li>If <b>Use regex</b> is on, each entry in Wait/Reset paths is
|
|
542
|
+
compiled as a regular expression. <code>^</code>/<code>$</code>
|
|
543
|
+
anchors are not implicit — <code>path\d+</code> matches
|
|
544
|
+
<code>my_path1_test</code>.</li>
|
|
545
|
+
<li>For repeated paths in any-order mode with regex, paths are counted
|
|
546
|
+
greedily left-to-right. <code>["path_[12]", "path_2"]</code> never
|
|
547
|
+
completes because <code>path_2</code> arrivals get attributed to the
|
|
548
|
+
first regex.</li>
|
|
549
|
+
<li>When the same path arrives multiple times, only the latest value
|
|
550
|
+
is kept in the merged output.</li>
|
|
551
|
+
<li>Pad <b>Timeout</b> with a small overhead (~5–10 ms) for
|
|
552
|
+
evaluation time when working with very short windows.</li>
|
|
553
|
+
<li>When <b>Preserve queue</b> is on, the queue is saved to a JSON
|
|
554
|
+
file under <code>{userDir}/join-wait/{nodeId}.json</code> on close
|
|
555
|
+
and reloaded on next start.</li>
|
|
556
|
+
</ul>
|
|
261
557
|
</script>
|