node-red-contrib-uos-nats 1.3.68 → 1.3.72

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 CHANGED
@@ -109,6 +109,17 @@ Publishes your own data to the Data Hub.
109
109
 
110
110
  ---
111
111
 
112
+ ## Performance & Reliability
113
+ - **High-Speed Decoding (Filter-on-Decode):** The node now intelligently filters incoming data at the byte-level. Even if a provider sends thousands of variables, Node-RED only decodes the ones you have selected. This massively reduces CPU usage.
114
+ - **Large Buffers:** The internal NATS buffer has been increased (10MB) to handle event bursts (e.g. rapid switching).
115
+ - **Slow Consumer Warning:** If Node-RED cannot keep up, a "SLOW CONSUMER" warning will appear in the debug log to alert you of dropped messages.
116
+
117
+ ## UI Features
118
+ - **Grouped Variables:** Variables are automatically grouped by folder (prefix) in the selection list (e.g. `ur20._4com...`).
119
+ - **Smart Filtering:**
120
+ - **Read Node** shows all variables.
121
+ - **Write Node** automatically hides Read-Only variables to prevent errors.
122
+
112
123
  ## Troubleshooting
113
124
 
114
125
  - **Provider not visible?** Ensure **Provider ID** matches your **Client ID**. Easiest way: Leave Provider ID empty in the node.
package/lib/payloads.js CHANGED
@@ -218,38 +218,48 @@ export function decodeWriteVariablesCommand(buffer) {
218
218
  return decodeVariableList(cmd.variables());
219
219
  }
220
220
 
221
- export function decodeVariableList(list) {
221
+ export function decodeVariableList(list, whitelistIds = null) {
222
222
  if (!list)
223
223
  return [];
224
224
  const result = [];
225
+
226
+ // Optimization: Reuse holders to reduce GC pressure
227
+ const holderInt64 = new VariableValueInt64();
228
+ const holderFloat64 = new VariableValueFloat64();
229
+ const holderBoolean = new VariableValueBoolean();
230
+ const holderString = new VariableValueString();
231
+
225
232
  for (let i = 0; i < list.itemsLength(); i += 1) {
226
233
  const item = list.items(i);
227
234
  if (!item)
228
235
  continue;
236
+
237
+ // Optimization: Filter-on-Decode
238
+ // If whitelist is provided and this ID is NOT in it, skip decoding
239
+ if (whitelistIds && !whitelistIds.has(item.id())) {
240
+ continue;
241
+ }
242
+
229
243
  let decoded;
230
244
  switch (item.valueType()) {
231
245
  case VariableValue.Int64: {
232
- const holder = new VariableValueInt64();
233
- item.value(holder);
234
- decoded = Number(holder.value());
246
+ item.value(holderInt64);
247
+ decoded = Number(holderInt64.value());
235
248
  break;
236
249
  }
237
250
  case VariableValue.Float64: {
238
- const holder = new VariableValueFloat64();
239
- item.value(holder);
240
- decoded = holder.value();
251
+ item.value(holderFloat64);
252
+ decoded = holderFloat64.value();
241
253
  break;
242
254
  }
243
255
  case VariableValue.Boolean: {
244
- const holder = new VariableValueBoolean();
245
- item.value(holder);
246
- decoded = holder.value();
256
+ item.value(holderBoolean);
257
+ decoded = holderBoolean.value();
247
258
  break;
248
259
  }
249
260
  case VariableValue.String: {
250
- const holder = new VariableValueString();
251
- item.value(holder);
252
- decoded = holder.value();
261
+ item.value(holderString);
262
+ decoded = holderString.value();
253
263
  break;
254
264
  }
255
265
  default:
@@ -155,34 +155,123 @@
155
155
 
156
156
  const selectedMap = getSelectedMap();
157
157
 
158
+ // Grouping Logic
159
+ const groups = {};
160
+ const noGroup = [];
161
+
158
162
  vars.forEach(v => {
163
+ const key = v.key || '';
164
+ const parts = key.split('.');
165
+ if (parts.length > 1) {
166
+ const groupName = parts[0];
167
+ if (!groups[groupName]) groups[groupName] = [];
168
+ groups[groupName].push(v);
169
+ } else {
170
+ noGroup.push(v);
171
+ }
172
+ });
173
+
174
+ // Helper to render a single row
175
+ const createRow = (v) => {
159
176
  let rawId = (v.id !== undefined && v.id !== null) ? v.id : v.Id;
160
177
  const safeId = (rawId !== undefined && rawId !== null) ? rawId : 'ERR';
161
178
  const isSelected = selectedMap.has(v.key);
162
179
 
163
180
  const row = $('<div>', { class: 'var-row', style: rowStyle });
164
-
165
181
  const cbContainer = $('<div>', { style: 'text-align:center;' });
166
182
  const cb = $('<input type="checkbox" class="var-checkbox">')
167
183
  .prop('checked', isSelected)
168
184
  .data('key', v.key)
169
185
  .data('id', safeId);
170
186
  cbContainer.append(cb);
171
-
172
187
  const label = $('<div>', { style: keyStyle, title: v.key }).text(v.key);
173
-
174
188
  row.append(cbContainer).append(label);
175
- $listContainer.append(row);
189
+ return row;
190
+ };
191
+
192
+ // Render Groups
193
+ Object.keys(groups).sort().forEach(groupName => {
194
+ const groupVars = groups[groupName];
195
+
196
+ // Header
197
+ const header = $('<div>', {
198
+ style: 'background:#eee; padding:5px 10px; font-weight:bold; cursor:pointer; border-bottom:1px solid #ddd; display:flex; align-items:center;'
199
+ });
200
+ const icon = $('<i class="fa fa-caret-right" style="margin-right:5px; width:10px;"></i>');
201
+ header.append(icon).append($('<span>').text(groupName + ` (${groupVars.length})`));
202
+
203
+ // Container for items
204
+ const itemContainer = $('<div>', { style: 'display:none; padding-left:0;' });
205
+
206
+ groupVars.forEach(v => {
207
+ itemContainer.append(createRow(v));
208
+ });
209
+
210
+ // Toggle Logic
211
+ header.click(function () {
212
+ const isVisible = itemContainer.is(':visible');
213
+ itemContainer.toggle(!isVisible);
214
+ icon.removeClass(isVisible ? 'fa-caret-down' : 'fa-caret-right').addClass(isVisible ? 'fa-caret-right' : 'fa-caret-down');
215
+ });
216
+
217
+ // Auto-expand if any child is selected OR if filtering
218
+ const hasSelection = groupVars.some(v => selectedMap.has(v.key));
219
+ if (hasSelection) {
220
+ header.click(); // Expand
221
+ }
222
+
223
+ $listContainer.append(header).append(itemContainer);
176
224
  });
225
+
226
+ // Render Ungrouped
227
+ if (noGroup.length > 0) {
228
+ const header = $('<div>', {
229
+ style: 'background:#eee; padding:5px 10px; font-weight:bold; border-bottom:1px solid #ddd; color:#666;'
230
+ }).text('Other Variables');
231
+ $listContainer.append(header);
232
+
233
+ noGroup.forEach(v => {
234
+ $listContainer.append(createRow(v));
235
+ });
236
+ }
177
237
  };
178
238
 
239
+ // Adjusted filter to open groups when searching
179
240
  const filterList = (term) => {
180
- const rows = $listContainer.find('.var-row');
181
- term = term.toLowerCase();
182
- rows.each(function () {
183
- const text = $(this).find('div:nth-child(2)').text().toLowerCase(); // 2nd child is name (grid)
184
- $(this).toggle(text.indexOf(term) > -1);
241
+ const termLower = term.toLowerCase();
242
+
243
+ // Show all containers first to filter their rows
244
+ $listContainer.children().show();
245
+
246
+ // Iterate groups (header + div container pairs)
247
+ // Group Headers are usually direct children?
248
+ // Actually, logic needs to be robust.
249
+
250
+ // Simplest: Re-render? No, state/checkboxes lost.
251
+ // Hiding logic:
252
+ $listContainer.find('.var-row').each(function () {
253
+ const text = $(this).text().toLowerCase();
254
+ const match = text.indexOf(termLower) > -1;
255
+ $(this).toggle(match);
256
+
257
+ // If matching, ensure parent group container is visible
258
+ if (match) {
259
+ $(this).parent().show();
260
+ // Ensure header icon is correct (caret down)
261
+ const container = $(this).parent();
262
+ const header = container.prev();
263
+ if (header.find('.fa-caret-right').length) {
264
+ header.find('.fa').removeClass('fa-caret-right').addClass('fa-caret-down');
265
+ }
266
+ }
185
267
  });
268
+
269
+ // Hide empty group headers/containers?
270
+ // Let's keep it simple for now: Open all if search term exists
271
+ if (termLower.length > 0) {
272
+ $listContainer.find('div[style*="display:none"]').show();
273
+ $listContainer.find('.fa-caret-right').removeClass('fa-caret-right').addClass('fa-caret-down');
274
+ }
186
275
  };
187
276
 
188
277
  $searchInput.on('keyup', function () {
@@ -218,7 +307,7 @@
218
307
  // We generally don't. But fetch providers works if deployed.
219
308
 
220
309
  $.ajax({
221
- url: 'uos/providers/' + configNodeId + '/' + providerId + '/variables',
310
+ url: 'uos/providers/' + configNodeId + '/' + providerId + '/variables?mode=read',
222
311
  success: function (data) {
223
312
  $fetchBtn.prop('disabled', false);
224
313
  $statusMsg.text('');
@@ -342,6 +431,12 @@
342
431
 
343
432
  <script type="text/html" data-help-name="datahub-input">
344
433
  <p><b>DataHub - Read</b> reads variables from u-OS Data Hub providers.</p>
434
+ <h3>Features</h3>
435
+ <ul>
436
+ <li><b>Smart Filtering:</b> Only selected variables are decoded (Filter-on-Decode), significantly reducing CPU usage.</li>
437
+ <li><b>Grouped Selection:</b> Variables are organized by prefix (e.g., Device Name) for easier navigation.</li>
438
+ <li><b>High Performance:</b> Enhanced buffering handles rapid data bursts reliably.</li>
439
+ </ul>
345
440
  <h3>Configuration</h3>
346
441
  <ol>
347
442
  <li><b>Config:</b> Select your u-OS connection.</li>
@@ -74,12 +74,14 @@ module.exports = function (RED) {
74
74
  // Pre-populate raw map with manual definitions
75
75
  this.manualDefs.forEach(d => defMap.set(d.id, { ...d, type: 'MANUAL', dataType: 'UNKNOWN', access: 'READ_ONLY' }));
76
76
 
77
+ // Optimization: Use Set for O(1) lookups
78
+ const variableSet = new Set(this.variables.map(v => normalizeKey(v)));
79
+
77
80
  const shouldInclude = (key) => {
78
- if (!this.variables.length) {
81
+ if (variableSet.size === 0) {
79
82
  return true;
80
83
  }
81
- const needle = normalizeKey(key);
82
- return this.variables.includes(needle);
84
+ return variableSet.has(normalizeKey(key));
83
85
  };
84
86
 
85
87
  const processStates = (states) => {
@@ -101,6 +103,12 @@ module.exports = function (RED) {
101
103
  this.warnOnce('Filtering active but Variable Definitions failed to load (API Error). Names cannot be resolved. Try using "Name:ID" format to manually map variables.');
102
104
  }
103
105
 
106
+ // Optimization: redundant check removed if whitelist is active, but keeping as safeguard
107
+ // If we used a whitelist, we know we only have relevant IDs.
108
+ // However, keeping strict check is safer for "Name-based" consistency.
109
+ // But we can optimize: if mapped.length is same as whitelist size, we are good?
110
+ // Actually, let's keep it simple: Filter-on-Decode handles the heavy lifting (byte level).
111
+ // This JS filter is now cheap (O(1) lookup). We'll keep it for correctness in case of aliasing/manual IDs.
104
112
  return mapped.filter((state) => shouldInclude(state.key));
105
113
  };
106
114
 
@@ -219,7 +227,9 @@ module.exports = function (RED) {
219
227
 
220
228
  const bb = new flatbuffers.ByteBuffer(snapshotMsg.data);
221
229
  const snapshotObj = ReadVariablesQueryResponse.getRootAsReadVariablesQueryResponse(bb);
222
- const states = payloads.decodeVariableList(snapshotObj.variables());
230
+ // Optimization: Pass activeWhitelist to decoder
231
+ // Although snapshot usually contains only requested IDs, this adds safety/speed for full snapshots
232
+ const states = payloads.decodeVariableList(snapshotObj.variables(), activeWhitelist);
223
233
 
224
234
  const filteredSnapshot = processStates(states);
225
235
  if (filteredSnapshot.length > 0) {
@@ -267,6 +277,20 @@ module.exports = function (RED) {
267
277
 
268
278
 
269
279
 
280
+ // Create Whitelist Set for Optimized Decoding
281
+ const whitelistIds = new Set();
282
+ // Resolve keys to IDs and populate whitelist
283
+ if (this.variables.length > 0) {
284
+ const requestedKeys = new Set(this.variables);
285
+ for (const def of defMap.values()) {
286
+ if (requestedKeys.has(def.key)) {
287
+ whitelistIds.add(Number(def.id));
288
+ }
289
+ }
290
+ }
291
+ // If whitelist is empty (Wildcard mode), pass null to decoder to read ALL.
292
+ const activeWhitelist = whitelistIds.size > 0 ? whitelistIds : null;
293
+
270
294
  await performSnapshot();
271
295
 
272
296
  // RECONNECT LOGIC
@@ -284,12 +308,21 @@ module.exports = function (RED) {
284
308
  }
285
309
 
286
310
  this.log(`Subscribing to changes for ${this.providerId}...`);
287
- sub = nc.subscribe(subjects.varsChangedEvent(this.providerId));
311
+
312
+ // Optimization: Increase maxMessages/maxBytes pending limits
313
+ // Default is often small (e.g. 65k bytes or related msg count). Raising to prevent SlowConsumer on bursts.
314
+ sub = nc.subscribe(subjects.varsChangedEvent(this.providerId), {
315
+ maxMessages: 10000,
316
+ maxBytes: 10 * 1024 * 1024 // 10MB Puffer
317
+ });
318
+
288
319
  (async () => {
289
320
  for await (const msg of sub) {
290
321
  const eventBB = new flatbuffers.ByteBuffer(msg.data);
291
322
  const event = VariablesChangedEvent.getRootAsVariablesChangedEvent(eventBB);
292
- const changeStates = payloads.decodeVariableList(event.changedVariables());
323
+ // Optimization: Pass activeWhitelist to decoder
324
+ const changeStates = payloads.decodeVariableList(event.changedVariables(), activeWhitelist);
325
+
293
326
  const filtered = processStates(changeStates);
294
327
  if (filtered.length === 0) {
295
328
  continue;
@@ -309,6 +342,18 @@ module.exports = function (RED) {
309
342
  this.warn(`subscription error: ${err.message}`);
310
343
  }
311
344
  });
345
+
346
+ // Optimization: Monitor for Slow Consumer / Dropped Messages via status iterator
347
+ (async () => {
348
+ if (sub && sub.status) {
349
+ for await (const s of sub.status()) {
350
+ if (s.type === 'slow_consumer') {
351
+ this.warn(`SLOW CONSUMER DETECTED! Use less variables or faster CPU. Dropped: ${s.data}`);
352
+ this.status({ fill: 'red', shape: 'dot', text: 'slow consumer (drops)' });
353
+ }
354
+ }
355
+ }
356
+ })().catch(() => { }); // Ignore status loop errors
312
357
  };
313
358
 
314
359
  connection.on('reconnected', () => {
@@ -85,7 +85,24 @@
85
85
  return;
86
86
  }
87
87
 
88
+ // Grouping Logic
89
+ const groups = {};
90
+ const noGroup = [];
91
+
88
92
  vars.forEach(v => {
93
+ const key = v.key || '';
94
+ const parts = key.split('.');
95
+ if (parts.length > 1) {
96
+ const groupName = parts[0];
97
+ if (!groups[groupName]) groups[groupName] = [];
98
+ groups[groupName].push(v);
99
+ } else {
100
+ noGroup.push(v);
101
+ }
102
+ });
103
+
104
+ // Helper to render a single row
105
+ const createRow = (v) => {
89
106
  const safeId = (v.id !== undefined) ? v.id : (v.Id !== undefined ? v.Id : 'ERR');
90
107
  const isSelected = String(v.key) === String($inputKey.val()); // Match by Key primarily now
91
108
 
@@ -97,6 +114,7 @@
97
114
  row.append(cbContainer).append(keyCol);
98
115
 
99
116
  const selectRow = () => {
117
+ // Radio Behavior for Write Node (Single Select)
100
118
  $('.var-checkbox').prop('checked', false);
101
119
  cb.prop('checked', true);
102
120
  $inputId.val(safeId !== 'ERR' ? safeId : '');
@@ -109,20 +127,94 @@
109
127
  row.on('mouseenter', function () { $(this).css('background', '#f7f7f7'); });
110
128
  row.on('mouseleave', function () { $(this).css('background', 'transparent'); });
111
129
 
112
- $listContainer.append(row);
130
+ return row;
131
+ };
132
+
133
+ // Render Groups
134
+ Object.keys(groups).sort().forEach(groupName => {
135
+ const groupVars = groups[groupName];
136
+
137
+ // Header
138
+ const header = $('<div>', {
139
+ style: 'background:#eee; padding:5px 10px; font-weight:bold; cursor:pointer; border-bottom:1px solid #ddd; display:flex; align-items:center;'
140
+ });
141
+ const icon = $('<i class="fa fa-caret-right" style="margin-right:5px; width:10px;"></i>');
142
+ header.append(icon).append($('<span>').text(groupName + ` (${groupVars.length})`));
143
+
144
+ // Container for items
145
+ const itemContainer = $('<div>', { style: 'display:none; padding-left:0;' });
146
+
147
+ groupVars.forEach(v => {
148
+ itemContainer.append(createRow(v));
149
+ });
150
+
151
+ // Toggle Logic
152
+ header.click(function () {
153
+ const isVisible = itemContainer.is(':visible');
154
+ itemContainer.toggle(!isVisible);
155
+ icon.removeClass(isVisible ? 'fa-caret-down' : 'fa-caret-right').addClass(isVisible ? 'fa-caret-right' : 'fa-caret-down');
156
+ });
157
+
158
+ // Auto-expand if child is selected
159
+ const hasSelection = groupVars.some(v => String(v.key) === String($inputKey.val()));
160
+ if (hasSelection) {
161
+ header.click(); // Expand
162
+ }
163
+
164
+ $listContainer.append(header).append(itemContainer);
113
165
  });
114
166
 
115
- // Scroll to selection
167
+ // Render Ungrouped
168
+ if (noGroup.length > 0) {
169
+ const header = $('<div>', {
170
+ style: 'background:#eee; padding:5px 10px; font-weight:bold; border-bottom:1px solid #ddd; color:#666;'
171
+ }).text('Other Variables');
172
+ $listContainer.append(header);
173
+
174
+ noGroup.forEach(v => {
175
+ $listContainer.append(createRow(v));
176
+ });
177
+ }
178
+
179
+ // Scroll to selection (simple attempt)
116
180
  const selectedCb = $listContainer.find('.var-checkbox:checked');
117
- if (selectedCb.length) $listContainer.scrollTop(selectedCb.closest('.var-row').offset().top - $listContainer.offset().top + $listContainer.scrollTop() - 40);
181
+ if (selectedCb.length) {
182
+ // Try to scroll parent container
183
+ // This is harder with hidden groups, but auto-expand handles visibility
184
+ // Scroll logic might need delay or calculating offsets of visible items.
185
+ // Skipping precise scroll for now.
186
+ }
118
187
  };
119
188
 
120
- $searchInput.on('keyup', function () {
121
- const term = $(this).val().toLowerCase();
189
+ // Adjusted filter to open groups when searching
190
+ const filterList = (term) => {
191
+ const termLower = term.toLowerCase();
192
+ $listContainer.children().show();
193
+
122
194
  $listContainer.find('.var-row').each(function () {
123
- const text = $(this).find('div:nth-child(2)').text().toLowerCase();
124
- $(this).toggle(text.indexOf(term) > -1);
195
+ const text = $(this).text().toLowerCase();
196
+ const match = text.indexOf(termLower) > -1;
197
+ $(this).toggle(match);
198
+
199
+ if (match) {
200
+ const container = $(this).parent();
201
+ container.show();
202
+ // Ensure header icon matches state
203
+ const header = container.prev();
204
+ if (header.find('.fa-caret-right').length) {
205
+ header.find('.fa').removeClass('fa-caret-right').addClass('fa-caret-down');
206
+ }
207
+ }
125
208
  });
209
+
210
+ if (termLower.length > 0) {
211
+ $listContainer.find('div[style*="display:none"]').show();
212
+ $listContainer.find('.fa-caret-right').removeClass('fa-caret-right').addClass('fa-caret-down');
213
+ }
214
+ };
215
+
216
+ $searchInput.on('keyup', function () {
217
+ filterList($(this).val());
126
218
  });
127
219
 
128
220
  $fetchBtn.on('click', function () {
@@ -136,7 +228,10 @@
136
228
  $statusMsg.text('Fetching...').css('color', 'blue');
137
229
  $listContainer.html('<div style="padding:20px; text-align:center;"><i class="fa fa-spinner fa-spin"></i> Loading...</div>');
138
230
 
139
- $.getJSON('uos/providers/' + configNodeId + '/' + providerId + '/variables', function (data) {
231
+ $listContainer.html('<div style="padding:20px; text-align:center;"><i class="fa fa-spinner fa-spin"></i> Loading...</div>');
232
+
233
+ // Request filtering from server
234
+ $.getJSON('uos/providers/' + configNodeId + '/' + providerId + '/variables?mode=write', function (data) {
140
235
  $fetchBtn.prop('disabled', false);
141
236
  $statusMsg.text('');
142
237
 
@@ -242,11 +337,12 @@
242
337
  </script>
243
338
 
244
339
  <script type="text/html" data-help-name="datahub-write">
245
- <p><b>DataHub - Write</b> sends write commands to external Data Hub providers.</p>
340
+ <p><b>DataHub - Write</b> sends write commands to external Data Hub providers.
341
+ It now features <b>Smart Filtering</b> to show only writable variables and <b>Grouped Lists</b> for better organization.</p>
246
342
  <h3>Configuration</h3>
247
343
  <ol>
248
- <li><b>Provider ID:</b> Target provider (e.g. <code>u_os_adm</code>). Use the search button to find it.</li>
249
- <li><b>Variable:</b> Select a target variable. <b>Only writable variables are shown.</b></li>
344
+ <li><b>Provider ID:</b> Target provider (e.g., <code>u_os_adm</code>). Use the search button to find it.</li>
345
+ <li><b>Variable:</b> Select a target variable. <b>Only variables with Write access are displayed.</b></li>
250
346
  </ol>
251
347
  <p>If no variable is selected, the node works in <b>Batch Mode</b> or <b>Dynamic Mode</b>.</p>
252
348
  <ul>
@@ -708,6 +708,28 @@ module.exports = function (RED) {
708
708
  });
709
709
  }
710
710
 
711
+ // Filter by Mode
712
+ const mode = req.query.mode; // 'read', 'write', or defaults to all
713
+
714
+ if (mode && Array.isArray(variables)) {
715
+ variables = variables.filter(v => {
716
+ // Safe Access Type check (defaults to READ_ONLY if missing)
717
+ // access is usually: 'READ_ONLY' or 'READ_WRITE'
718
+ const access = String(v.access || v.accessType || 'READ_ONLY').toUpperCase();
719
+
720
+ if (mode === 'write') {
721
+ // WRITE requires READ_WRITE (or WRITE_ONLY if that existed)
722
+ return access.includes('WRITE');
723
+ } else if (mode === 'read') {
724
+ // READ allows everything (READ_ONLY and READ_WRITE)
725
+ // So theoretically we return everything.
726
+ // But maybe the user wants to filter OUT Write-Only? (Not common in u-OS)
727
+ return true;
728
+ }
729
+ return true;
730
+ });
731
+ }
732
+
711
733
  res.json(variables);
712
734
  }
713
735
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-uos-nats",
3
- "version": "1.3.68",
3
+ "version": "1.3.72",
4
4
  "description": "Node-RED nodes for Weidmüller u-OS Data Hub. Read, write, and provide variables via NATS protocol with OAuth2 authentication. Features: Variable Key resolution, custom icons, example flows, and provider definition caching.",
5
5
  "author": {
6
6
  "name": "IoTUeli",
@@ -59,6 +59,11 @@
59
59
  "datahub-write": "nodes/datahub-write.js"
60
60
  }
61
61
  },
62
+ "scripts": {
63
+ "build:offline": "node scripts/build-offline.js",
64
+ "generate:fbs": "bash scripts/generate-fbs.sh",
65
+ "test": "echo \"Error: no test specified\" && exit 1"
66
+ },
62
67
  "devDependencies": {
63
68
  "dotenv": "^17.2.3"
64
69
  }