smart-home-engine 2.0.0 → 2.1.0

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.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-DLP7FxoS.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-DtW7-hC9.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -175,10 +175,10 @@
175
175
  }
176
176
  })();
177
177
  </script>
178
- <script type="module" crossorigin src="/assets/index-DLP7FxoS.js"></script>
178
+ <script type="module" crossorigin src="/assets/index-DtW7-hC9.js"></script>
179
179
  <link rel="modulepreload" crossorigin href="/assets/monaco-langs-DKJI_o9J.js">
180
180
  <link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
181
- <link rel="stylesheet" crossorigin href="/assets/index-Bvl2uECO.css">
181
+ <link rel="stylesheet" crossorigin href="/assets/index-Bu5o4KXc.css">
182
182
  </head>
183
183
  <body>
184
184
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Node.js based script runner for use in MQTT based Smart Home environments",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/index.js CHANGED
@@ -268,7 +268,9 @@ const { domain, vm, fs, path, suncalc } = modules;
268
268
  const scheduler = modules['node-schedule'];
269
269
 
270
270
  const StateStore = require('./lib/state-store');
271
- const { trackTimeout } = require('./lib/script-timers');
271
+ const { trackTimeout, isValidDelay, describeDelay } = require('./lib/script-timers');
272
+ const timing = require('./lib/timing');
273
+ const publishChanged = require('./lib/publish-changed');
272
274
  const sandboxModules = [];
273
275
  const store = new StateStore();
274
276
  if (typeof config.port !== 'undefined') require('./web/broker-api').setStore(store);
@@ -282,9 +284,20 @@ const varSubscriptions = []; // store-based var:: subscriptions { key, handler,
282
284
  const scriptJobs = new Map(); // scriptFile → node-schedule Job[]
283
285
  const scriptPublishes = new Map(); // scriptFile → Set<topic> a script published to (roadmap I19; capped)
284
286
  const scriptTimers = new Map(); // scriptFile → Set<timer id>
287
+ const scriptUnloadHooks = new Map(); // scriptFile → Set<fn> run first when the script is unloaded
285
288
 
286
289
  const _global = {};
287
290
 
291
+ // One store listener serves every she.on('var::…') of every script. Before, each call
292
+ // added a listener of its own, so Node warned about MaxListeners from the eleventh on; now a
293
+ // registration is an entry in varSubscriptions, which unloadScript removes.
294
+ store.on('change', (changedKey, val, obj, prevObj) => {
295
+ if (!changedKey.startsWith('var::')) return;
296
+ for (const sub of varSubscriptions.slice()) {
297
+ if (sub.key === changedKey) _dispatch(sub._script, () => sub.callback(val, obj, prevObj));
298
+ }
299
+ });
300
+
288
301
  // ── Event-loop heartbeat ─────────────────────────────────────────────────────
289
302
  let _blockingScript = null;
290
303
  let _blockingScriptTs = 0; // timestamp when the last _dispatch() call finished
@@ -620,7 +633,7 @@ const introspect = {
620
633
  publishes: [...(scriptPublishes.get(file) || [])],
621
634
  jobs: (scriptJobs.get(file) || []).filter(Boolean).map((job) => ({ next: nextOf(job) })),
622
635
  sunEvents: sunEvents.filter((e) => e._script === file).map((e) => ({ pattern: e.pattern, next: nextOf(e._job) })),
623
- timers: [...(scriptTimers.get(file) || [])].map((t) => ({ due: t.due || null, every: t.every || null })),
636
+ timers: [...(scriptTimers.get(file) || [])].map((t) => ({ due: t.due || null, every: t.every || null, kind: t.kind || null, name: t.name || null })),
624
637
  })),
625
638
  stats: statsSnapshot,
626
639
  health: healthSnapshot,
@@ -935,17 +948,25 @@ function stateChange(topic, state, oldState, msg) {
935
948
 
936
949
  delay = Math.floor(delay);
937
950
 
938
- setTimeout(() => {
939
- /**
940
- * @callback subscribeCallback
941
- * @param {string} topic - the topic that triggered this callback
942
- * @param {mixed} val - the val property of the new state
943
- * @param {object} obj - new state - the whole state object (e.g. {"val": true, "ts": 12346345, "lc": 12346345} )
944
- * @param {object} objPrev - previous state - the whole state object
945
- * @param {object} msg - the mqtt message as received from MQTT.js
946
- */
947
- _dispatch(subs._script, () => subs.callback(topic, state.val, state, oldState, msg));
948
- }, delay);
951
+ // The delay timer is tracked like a script's own setTimeout, so a reload of
952
+ // the script between the message and the shifted callback cancels the callback instead
953
+ // of running the unloaded script's code.
954
+ trackTimeout(
955
+ scriptTimers.get(subs._script) || new Set(),
956
+ () => {
957
+ /**
958
+ * @callback subscribeCallback
959
+ * @param {string} topic - the topic that triggered this callback
960
+ * @param {mixed} val - the val property of the new state
961
+ * @param {object} obj - new state - the whole state object (e.g. {"val": true, "ts": 12346345, "lc": 12346345} )
962
+ * @param {object} objPrev - previous state - the whole state object
963
+ * @param {object} msg - the mqtt message as received from MQTT.js
964
+ */
965
+ subs.deliver(topic, state.val, state, oldState, msg);
966
+ },
967
+ delay,
968
+ delay > 0 ? { kind: 'shift', name: subs.topic } : {},
969
+ );
949
970
  }
950
971
  });
951
972
  }
@@ -1004,6 +1025,14 @@ function runScript(script, name, _origin) {
1004
1025
  if (!scriptTimers.has(name)) scriptTimers.set(name, new Set());
1005
1026
  const _myJobs = scriptJobs.get(name);
1006
1027
  const _myTimers = scriptTimers.get(name);
1028
+ const _myHooks = new Set();
1029
+ scriptUnloadHooks.set(name, _myHooks);
1030
+ const _myLastPayload = new Map(); // topic → last payload, for pub({ changed: true })
1031
+ let _unloaded = false;
1032
+ let _subCount = 0;
1033
+ _myHooks.add(() => {
1034
+ _unloaded = true;
1035
+ });
1007
1036
 
1008
1037
  log.debug(logLabel, 'creating domain');
1009
1038
  const scriptDomain = domain.create();
@@ -1014,6 +1043,41 @@ function runScript(script, name, _origin) {
1014
1043
  return a !== null && typeof a === 'object' ? JSON.stringify(a) : a;
1015
1044
  }
1016
1045
 
1046
+ /**
1047
+ * The script's one-shot timer: the delay is validated and a bad one is logged with the script's
1048
+ * name, the callback runs in the script's domain, the id is tracked for the unload
1049
+ * and carries kind and name for the timer list. Every timer API of the sandbox goes through here.
1050
+ */
1051
+ function _timeout(fn, delay, meta = {}, api = 'setTimeout') {
1052
+ if (_unloaded) {
1053
+ log.debug(logLabel, `${api} after unload ignored`);
1054
+ return null;
1055
+ }
1056
+ return trackTimeout(
1057
+ _myTimers,
1058
+ scriptDomain.bind(() => _dispatch(name, fn)),
1059
+ delay,
1060
+ {
1061
+ warn: (msg) => log.warn(logLabel, msg),
1062
+ api,
1063
+ kind: meta.kind,
1064
+ name: meta.name,
1065
+ },
1066
+ );
1067
+ }
1068
+ function _clearTimeout(id) {
1069
+ _myTimers.delete(id);
1070
+ clearTimeout(id);
1071
+ }
1072
+ const _timing = timing.createTimingHelpers({
1073
+ setTimeout: (fn, ms, meta) => _timeout(fn, ms, meta),
1074
+ clearTimeout: _clearTimeout,
1075
+ onUnload: (fn) => {
1076
+ _myHooks.add(fn);
1077
+ return () => _myHooks.delete(fn);
1078
+ },
1079
+ });
1080
+
1017
1081
  const she = {
1018
1082
  global: _global,
1019
1083
 
@@ -1087,36 +1151,52 @@ function runScript(script, name, _origin) {
1087
1151
  } else if (rest.length > 2) {
1088
1152
  throw new Error('wrong number of arguments');
1089
1153
  }
1154
+ options = options || {};
1090
1155
 
1091
- if (typeof topic === 'string') {
1092
- if (typeof options.condition === 'string') {
1093
- if (options.condition.indexOf('\n') !== -1) {
1094
- throw new Error('options.condition string must be one-line javascript');
1095
- }
1096
- /* eslint-disable no-new-func */
1097
- options.condition = new Function('topic', 'val', 'obj', 'objPrev', 'msg', 'return ' + options.condition + ';');
1098
- }
1156
+ let topics;
1157
+ if (typeof topic === 'string') topics = [topic];
1158
+ else if (typeof topic === 'object' && topic.length > 0) topics = Array.prototype.slice.call(topic);
1159
+ else return;
1099
1160
 
1100
- if (typeof options.condition === 'function') {
1101
- options.condition = scriptDomain.bind(options.condition);
1161
+ if (typeof options.condition === 'string') {
1162
+ if (options.condition.indexOf('\n') !== -1) {
1163
+ throw new Error('options.condition string must be one-line javascript');
1102
1164
  }
1165
+ /* eslint-disable no-new-func */
1166
+ options.condition = new Function('topic', 'val', 'obj', 'objPrev', 'msg', 'return ' + options.condition + ';');
1167
+ }
1103
1168
 
1104
- subscriptions.push({ topic, options, callback: typeof callback === 'function' && scriptDomain.bind(callback), _script: name });
1169
+ if (typeof options.condition === 'function') {
1170
+ options.condition = scriptDomain.bind(options.condition);
1171
+ }
1105
1172
 
1106
- if (options.retain && store.has('mqtt::' + topic) && typeof callback === 'function') {
1107
- _dispatch(name, () => callback(topic, store.get('mqtt::' + topic), store.getObject('mqtt::' + topic)));
1108
- } else if (options.retain && (/\/\+\//.test(topic) || /\+$/.test(topic) || /\+/.test(topic) || topic.endsWith('#')) && typeof callback === 'function') {
1109
- for (const [t, obj] of store.mqttEntries()) {
1110
- if (mqttWildcards(t, topic)) {
1111
- _dispatch(name, () => callback(t, obj.val, obj));
1173
+ const boundCallback = typeof callback === 'function' && scriptDomain.bind(callback);
1174
+ // what runs the callback for one event; with opts.debounce or opts.throttle the events of
1175
+ // this call are bundled — across the topics of an array, per topic with opts.perTopic
1176
+ let deliver = (t, val, obj, objPrev, msg) => _dispatch(name, () => boundCallback(t, val, obj, objPrev, msg));
1177
+ const label = topics.length > 1 ? `${topics[0]} +${topics.length - 1}` : topics[0];
1178
+ const limited = timing.subscriptionLimiter(options, deliver, _timing, label);
1179
+ if (limited) deliver = limited;
1180
+
1181
+ for (const tp of topics) {
1182
+ subscriptions.push({ topic: tp, options, callback: boundCallback, deliver, _script: name });
1183
+ _subCount++;
1184
+ if (_subCount === 1000) {
1185
+ log.warn(
1186
+ logLabel,
1187
+ 'has registered 1000 subscriptions — she.mqtt.sub is meant to be called when the script loads, not inside a callback: every call adds a subscription that stays until the script is unloaded',
1188
+ );
1189
+ }
1190
+
1191
+ if (options.retain && boundCallback) {
1192
+ if (store.has('mqtt::' + tp)) {
1193
+ deliver(tp, store.get('mqtt::' + tp), store.getObject('mqtt::' + tp));
1194
+ } else if (tp.includes('+') || tp.endsWith('#')) {
1195
+ for (const [t, obj] of store.mqttEntries()) {
1196
+ if (mqttWildcards(t, tp)) deliver(t, obj.val, obj);
1112
1197
  }
1113
1198
  }
1114
1199
  }
1115
- } else if (typeof topic === 'object' && topic.length > 0) {
1116
- topic = Array.prototype.slice.call(topic);
1117
- topic.forEach((tp) => {
1118
- she.mqttsub(tp, options, callback);
1119
- });
1120
1200
  }
1121
1201
  },
1122
1202
 
@@ -1179,17 +1259,30 @@ function runScript(script, name, _origin) {
1179
1259
  return;
1180
1260
  }
1181
1261
 
1182
- if (options.random) {
1183
- _myJobs.push(
1184
- scheduler.scheduleJob(pattern, () => {
1185
- // Track the random-delay timer so it is cancelled on unload
1186
- // if the job fires in the same tick as the script is reloaded.
1187
- trackTimeout(_myTimers, () => _dispatch(name, scriptDomain.bind(callback)), (parseFloat(options.random) || 0) * 1000 * Math.random());
1188
- }),
1189
- );
1190
- } else {
1191
- _myJobs.push(scheduler.scheduleJob(pattern, () => _dispatch(name, scriptDomain.bind(callback))));
1262
+ const run = options.random
1263
+ ? () => {
1264
+ // Track the random-delay timer so it is cancelled on unload
1265
+ // if the job fires in the same tick as the script is reloaded.
1266
+ _timeout(scriptDomain.bind(callback), (parseFloat(options.random) || 0) * 1000 * Math.random(), { kind: 'schedule random' }, 'she.schedule');
1267
+ }
1268
+ : () => _dispatch(name, scriptDomain.bind(callback));
1269
+ let job = null;
1270
+ job = scheduler.scheduleJob(pattern, () => {
1271
+ // A one-shot job (a Date) is done after this run: forget it, so that a script
1272
+ // scheduling a Date per event does not keep every fired job.
1273
+ if (job && job.nextInvocation() === null) {
1274
+ const i = _myJobs.indexOf(job);
1275
+ if (i !== -1) _myJobs.splice(i, 1);
1276
+ }
1277
+ run();
1278
+ });
1279
+ if (!job) {
1280
+ // a Date from the sandbox is of the context's realm, so no instanceof
1281
+ const shown = pattern && typeof pattern.toISOString === 'function' ? pattern.toISOString() : JSON.stringify(pattern);
1282
+ log.warn(logLabel, `she.schedule: nothing scheduled for ${shown} — not a valid pattern, or a date in the past`);
1283
+ return;
1192
1284
  }
1285
+ _myJobs.push(job);
1193
1286
  },
1194
1287
 
1195
1288
  /**
@@ -1200,6 +1293,8 @@ function runScript(script, name, _origin) {
1200
1293
  * @param {Object} [options] - the options to publish with
1201
1294
  * @param {number} [options.qos=0] - QoS Level
1202
1295
  * @param {boolean} [options.retain=false] - retain flag
1296
+ * @param {boolean} [options.changed=false] - publish only when the payload differs from the last one this
1297
+ * script published to the topic (before the first one: from the topic's current value in the state store)
1203
1298
  */
1204
1299
  mqttpub: function Sandbox_mqttpub(topic, payload, options) {
1205
1300
  if (typeof topic === 'object' && topic.length > 0) {
@@ -1218,7 +1313,13 @@ function runScript(script, name, _origin) {
1218
1313
  } else {
1219
1314
  payload = String(payload);
1220
1315
  }
1316
+ let changed = false;
1317
+ if (options && typeof options === 'object' && 'changed' in options) {
1318
+ ({ changed, ...options } = options);
1319
+ }
1320
+ if (changed && publishChanged.isRepeat(_myLastPayload, topic, payload, (t) => store.getObject('mqtt::' + t))) return;
1221
1321
  if (!mqtt || !connected) return; // silently drop when MQTT is not connected
1322
+ publishChanged.remember(_myLastPayload, topic, payload);
1222
1323
  mqtt.publish(topic, payload, options);
1223
1324
  },
1224
1325
 
@@ -1308,11 +1409,8 @@ function runScript(script, name, _origin) {
1308
1409
  } else if (key.startsWith('var::')) {
1309
1410
  const varStoreKey = 'var::' + key.slice(5);
1310
1411
  const boundCb = scriptDomain.bind(callback);
1311
- const varHandler = (changedKey, val, obj, prevObj) => {
1312
- if (changedKey === varStoreKey) _dispatch(name, () => boundCb(val, obj, prevObj));
1313
- };
1314
- store.on('change', varHandler);
1315
- varSubscriptions.push({ key: varStoreKey, handler: varHandler, _script: name });
1412
+ // served by the one store listener at module level
1413
+ varSubscriptions.push({ key: varStoreKey, callback: boundCb, _script: name });
1316
1414
  // Fire immediately if var already has a value (retain semantics)
1317
1415
  const currentVarObj = store.getObject(varStoreKey);
1318
1416
  if (currentVarObj !== undefined) boundCb(currentVarObj.val, currentVarObj, undefined);
@@ -1332,22 +1430,30 @@ function runScript(script, name, _origin) {
1332
1430
  she.log = she.info;
1333
1431
 
1334
1432
  const Sandbox = {
1335
- setTimeout: (fn, delay, ...args) => {
1433
+ // setTimeout(fn) without a delay is Node's "next tick"; a delay that is given but is not a
1434
+ // number is a mistake and logged with the script's name
1435
+ setTimeout: (fn, ...rest) => {
1436
+ const [delay, ...args] = rest;
1336
1437
  const wrapped = args.length ? () => fn(...args) : fn;
1337
- return trackTimeout(_myTimers, () => _dispatch(name, wrapped), delay);
1438
+ return _timeout(wrapped, rest.length ? delay : 0);
1338
1439
  },
1339
- setInterval: (fn, delay, ...args) => {
1440
+ setInterval: (fn, ...rest) => {
1441
+ let [delay, ...args] = rest;
1442
+ if (!isValidDelay(delay)) {
1443
+ log.warn(logLabel, `setInterval: delay is not a number of milliseconds (got ${describeDelay(delay)}), using 0`);
1444
+ delay = 0;
1445
+ }
1340
1446
  const wrapped = args.length ? () => fn(...args) : fn;
1341
- const id = setInterval(() => _dispatch(name, wrapped), delay);
1447
+ const id = setInterval(
1448
+ scriptDomain.bind(() => _dispatch(name, wrapped)),
1449
+ delay,
1450
+ );
1342
1451
  id.every = delay;
1343
1452
  id.due = Date.now() + delay;
1344
1453
  _myTimers.add(id);
1345
1454
  return id;
1346
1455
  },
1347
- clearTimeout: (id) => {
1348
- _myTimers.delete(id);
1349
- clearTimeout(id);
1350
- },
1456
+ clearTimeout: _clearTimeout,
1351
1457
  clearInterval: (id) => {
1352
1458
  _myTimers.delete(id);
1353
1459
  clearInterval(id);
@@ -1356,7 +1462,11 @@ function runScript(script, name, _origin) {
1356
1462
  Buffer,
1357
1463
 
1358
1464
  require(md) {
1359
- if (modules[md]) {
1465
+ // A relative path is cached by Node under its resolved file; caching it here under the bare
1466
+ // specifier made two scripts requiring './util' from different directories share the first
1467
+ // one's module. The cache here is for the daemon's own modules and packages.
1468
+ const relative = /^\.\.?\//.test(md);
1469
+ if (!relative && modules[md]) {
1360
1470
  return modules[md];
1361
1471
  }
1362
1472
  // Failures throw (Cannot find module, syntax errors in the required
@@ -1384,7 +1494,7 @@ function runScript(script, name, _origin) {
1384
1494
  }
1385
1495
  }
1386
1496
  }
1387
- modules[md] = result;
1497
+ if (!relative) modules[md] = result;
1388
1498
  return result;
1389
1499
  },
1390
1500
 
@@ -1415,16 +1525,16 @@ function runScript(script, name, _origin) {
1415
1525
  },
1416
1526
  has: (p) => secrets.has(p),
1417
1527
  });
1418
- // she.setTimeout / she.clearTimeout — tracked versions for use by stdlib and
1419
- // sandbox modules that don't have direct access to the Sandbox context.
1420
- she.setTimeout = (fn, delay, ...args) => {
1421
- const wrapped = args.length ? () => fn(...args) : fn;
1422
- return trackTimeout(_myTimers, () => _dispatch(name, wrapped), delay);
1423
- };
1424
- she.clearTimeout = (id) => {
1425
- _myTimers.delete(id);
1426
- clearTimeout(id);
1427
- };
1528
+ // she.setTimeout / she.clearTimeout — the sandbox's tracked timers under the she object: the
1529
+ // documented aliases of the globals, and what stdlib and the sandbox modules use.
1530
+ she.setTimeout = Sandbox.setTimeout;
1531
+ she.clearTimeout = Sandbox.clearTimeout;
1532
+ // Timing helpers: their timers are the script's, so a reload clears them and
1533
+ // the timer list shows them with kind and name.
1534
+ she.debounce = _timing.debounce;
1535
+ she.throttle = _timing.throttle;
1536
+ she.timeout = _timing.timeout;
1537
+ she.retry = _timing.retry;
1428
1538
  sandboxModules.forEach((md) => {
1429
1539
  md(she, { scriptDomain, scriptName, scriptFile: name });
1430
1540
  });
@@ -1523,6 +1633,20 @@ function unloadScript(file) {
1523
1633
  let removedCallbacks = 0;
1524
1634
  let removedTimers = 0;
1525
1635
 
1636
+ // The script's unload hooks run first: the sandbox marks itself unloaded, a debounce
1637
+ // with flushOnUnload runs its pending call, a retry stops. Then the timers below are cleared.
1638
+ const hooks = scriptUnloadHooks.get(file);
1639
+ if (hooks) {
1640
+ scriptUnloadHooks.delete(file);
1641
+ for (const hook of hooks) {
1642
+ try {
1643
+ _dispatch(file, hook);
1644
+ } catch (err) {
1645
+ log.error(unloadLabel, 'error while unloading: ' + (err && err.stack ? err.stack : err));
1646
+ }
1647
+ }
1648
+ }
1649
+
1526
1650
  // Remove MQTT subscriptions belonging to this script
1527
1651
  for (let i = subscriptions.length - 1; i >= 0; i--) {
1528
1652
  if (subscriptions[i]._script === file) {
@@ -1572,7 +1696,6 @@ function unloadScript(file) {
1572
1696
  // Remove store-based var:: subscriptions belonging to this script
1573
1697
  for (let i = varSubscriptions.length - 1; i >= 0; i--) {
1574
1698
  if (varSubscriptions[i]._script === file) {
1575
- store.removeListener('change', varSubscriptions[i].handler);
1576
1699
  varSubscriptions.splice(i, 1);
1577
1700
  removedCallbacks++;
1578
1701
  }
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+ /**
3
+ * `she.mqtt.pub(topic, payload, { changed: true })`: publish only when the payload
4
+ * differs from what this script last sent to the topic. Before the first publish of a run, the
5
+ * comparison falls back to the topic's current value in the state store — for a command topic
6
+ * (`<prefix>/set/<name>`) to its `status` sibling, which is where a variable's or an adapter's
7
+ * state lives. The last payloads are kept per script, so a hot reload starts over with the store.
8
+ */
9
+
10
+ const parsePayload = require('./parse-payload');
11
+
12
+ const CAP = 200; // topics remembered per script, like the publish record
13
+
14
+ /**
15
+ * @param {Map<string,string>} last topic → the payload this script last published
16
+ * @param {string} topic
17
+ * @param {string} payload the stringified payload
18
+ * @param {Function} lookup (topic) → the state object { val, ts, lc } or undefined
19
+ * @returns {boolean} true when the publish would repeat the last one
20
+ */
21
+ function isRepeat(last, topic, payload, lookup) {
22
+ if (last.has(topic)) return last.get(topic) === payload;
23
+ let state = lookup(topic);
24
+ if (state === undefined) {
25
+ const parts = topic.split('/');
26
+ if (parts.length > 2 && parts[1] === 'set') {
27
+ parts[1] = 'status';
28
+ state = lookup(parts.join('/'));
29
+ }
30
+ }
31
+ if (state === undefined || state === null) return false;
32
+ return JSON.stringify(parsePayload(payload).val) === JSON.stringify(state.val);
33
+ }
34
+
35
+ /**
36
+ * Remember what was published; at most CAP topics per script.
37
+ * @param {Map<string,string>} last
38
+ * @param {string} topic
39
+ * @param {string} payload
40
+ */
41
+ function remember(last, topic, payload) {
42
+ if (last.size < CAP || last.has(topic)) last.set(topic, payload);
43
+ }
44
+
45
+ module.exports = { isRepeat, remember, CAP };
@@ -7,21 +7,62 @@
7
7
  * every timeout that ran normally stayed in the set for the life of the daemon — with its callback
8
8
  * and everything the callback captured. A script that starts a "switch off after N minutes" timer
9
9
  * per event leaked hundreds of them an hour, and the heap ran full after about twelve days.
10
- *
10
+ */
11
+
12
+ /**
13
+ * A delay a script passed to a timer API: a finite number of milliseconds, not negative.
14
+ * Node coerces anything else to NaN, warns once per process without naming the caller and fires
15
+ * after 1 ms — a swapped argument order or an undefined config field became a silent 1 ms timer.
16
+ * @param {*} delay
17
+ * @returns {boolean}
18
+ */
19
+ function isValidDelay(delay) {
20
+ return typeof delay === 'number' && Number.isFinite(delay) && delay >= 0;
21
+ }
22
+
23
+ /**
24
+ * How an invalid delay is described in the warning: `undefined`, `NaN`, `"5m"`, `an object`.
25
+ * @param {*} delay
26
+ * @returns {string}
27
+ */
28
+ function describeDelay(delay) {
29
+ if (typeof delay === 'string') return JSON.stringify(delay);
30
+ if (typeof delay === 'function') return 'a function';
31
+ if (delay !== null && typeof delay === 'object') return Array.isArray(delay) ? 'an array' : 'an object';
32
+ return String(delay);
33
+ }
34
+
35
+ /**
11
36
  * @param {Set} timers the script's timer set
12
37
  * @param {Function} fn called when the timer fires
13
- * @param {number} delay ms
14
- * @param {Function} [setTimeoutFn] for tests
15
- * @returns the timer id (what clearTimeout takes)
38
+ * @param {number} delay ms; an invalid delay is reported through `opts.warn` and runs as 0
39
+ * @param {object|Function} [opts] options, or (legacy) the setTimeout function to use
40
+ * @param {Function} [opts.setTimeoutFn] for tests
41
+ * @param {Function} [opts.warn] called with a message when the delay is not a number (the caller
42
+ * adds the script's label); without it an invalid delay is silently 0
43
+ * @param {string} [opts.api] the API name for the warning, default `setTimeout`
44
+ * @param {string} [opts.kind] shown in the timer list: `debounce`, `throttle`, `timeout`, `retry`
45
+ * @param {string} [opts.name] shown in the timer list next to the kind
46
+ * @returns the timer id (what clearTimeout takes); carries `due`, and `kind`/`name` when given
16
47
  */
17
- function trackTimeout(timers, fn, delay, setTimeoutFn = setTimeout) {
48
+ function trackTimeout(timers, fn, delay, opts = {}) {
49
+ if (typeof opts === 'function') opts = { setTimeoutFn: opts };
50
+ const setTimeoutFn = opts.setTimeoutFn || setTimeout;
51
+ if (!isValidDelay(delay)) {
52
+ if (typeof opts.warn === 'function') opts.warn(`${opts.api || 'setTimeout'}: delay is not a number of milliseconds (got ${describeDelay(delay)}), using 0`);
53
+ delay = 0;
54
+ }
18
55
  const id = setTimeoutFn(() => {
19
56
  timers.delete(id);
20
57
  fn();
21
58
  }, delay);
22
- if (id && typeof id === 'object') id.due = Date.now() + delay; // when it fires (roadmap I28: list_timers)
59
+ if (id && typeof id === 'object') {
60
+ id.due = Date.now() + delay; // when it fires (roadmap I28: list_timers)
61
+ if (opts.kind) id.kind = opts.kind;
62
+ if (opts.name) id.name = opts.name;
63
+ }
23
64
  timers.add(id);
24
65
  return id;
25
66
  }
26
67
 
27
- module.exports = { trackTimeout };
68
+ module.exports = { trackTimeout, isValidDelay, describeDelay };