@aaqu/fromcubes-portal-react 0.1.0-alpha.22 → 0.1.0-alpha.24

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,5 +1,34 @@
1
+ /** @module nodes/lib/helpers */
2
+
3
+ /**
4
+ * Shared helper functions for portal-react. Pure helpers (hash, validators,
5
+ * esbuild wrappers) are exported at module level; runtime helpers that need
6
+ * `RED` (cache, tailwind, preInstall hook) are produced by the default export
7
+ * factory.
8
+ */
9
+
1
10
  /**
2
- * Shared helper functions for portal-react.
11
+ * @typedef {Object} TranspileResult
12
+ * @property {string|null} js Compiled IIFE bundle, or null on error.
13
+ * @property {string|null} [error] Multi-line error description from esbuild.
14
+ * @property {Object} [metafile] Optional esbuild metafile (size analysis).
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} SubPathResult
19
+ * @property {boolean} ok
20
+ * @property {string} [value]
21
+ * @property {string} [error]
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} PortalUser
26
+ * @property {string} [userId]
27
+ * @property {string} [userName]
28
+ * @property {string} [username]
29
+ * @property {string} [email]
30
+ * @property {string} [role]
31
+ * @property {string|Array<string>}[groups]
3
32
  */
4
33
 
5
34
  const crypto = require("crypto");
@@ -7,6 +36,17 @@ const fs = require("fs");
7
36
  const path = require("path");
8
37
  const esbuild = require("esbuild");
9
38
 
39
+ // Cap on JSON-encoded `x-portal-user-groups` header — protects JSON.parse from
40
+ // being fed an unbounded payload by a misbehaving auth proxy / forged header.
41
+ // Same order of magnitude as Express default header size; trimming here so a
42
+ // 1MB string can't reach JSON.parse.
43
+ const MAX_GROUPS_HEADER_BYTES = 8 * 1024;
44
+
45
+ /**
46
+ * Short content hash used for cache keys (16 hex chars of sha256).
47
+ * @param {string} str
48
+ * @returns {string}
49
+ */
10
50
  function hash(str) {
11
51
  return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
12
52
  }
@@ -15,6 +55,13 @@ const twCompile = require("tailwindcss").compile;
15
55
  const CANDIDATE_RE = /[a-zA-Z0-9_\-:.\/\[\]#%]+/g;
16
56
 
17
57
  let twCompiled = null;
58
+ /**
59
+ * Lazily compile the Tailwind base stylesheet. Result is memoized at module
60
+ * scope — first call resolves async stylesheet imports, every subsequent
61
+ * call returns the cached compiler.
62
+ *
63
+ * @returns {Promise<Object>} Tailwind `compile()` result with a `.build()` method.
64
+ */
18
65
  async function getTwCompiled() {
19
66
  if (twCompiled) return twCompiled;
20
67
  twCompiled = await twCompile(`@import 'tailwindcss';`, {
@@ -34,6 +81,13 @@ async function getTwCompiled() {
34
81
  return twCompiled;
35
82
  }
36
83
 
84
+ /**
85
+ * Generate the per-page Tailwind CSS bundle by scanning `source` for utility
86
+ * class candidates and feeding them to the compiled Tailwind core.
87
+ *
88
+ * @param {string} source Source text to scan for class candidates.
89
+ * @returns {Promise<{css: string, cssHash: string}>}
90
+ */
37
91
  async function generateCSS(source) {
38
92
  const cssHash = hash(source);
39
93
  const compiled = await getTwCompiled();
@@ -42,17 +96,61 @@ async function generateCSS(source) {
42
96
  return { css, cssHash };
43
97
  }
44
98
 
45
- const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
99
+ // Component / utility names become JavaScript identifiers in the generated
100
+ // bundle (`const Name = (() => ...)();` for components; raw top-level decls
101
+ // for utilities). Enforce strict identifier syntax up-front so the diagnostic
102
+ // surfaces on the offending node — not as a confusing esbuild parse error on
103
+ // some unrelated portal that happens to import it.
104
+ const NAME_RE = /^[A-Za-z_$][\w$]*$/;
105
+ const NAME_MAX_LEN = 64;
106
+ const NAME_BLACKLIST = new Set([
107
+ "__proto__",
108
+ "constructor",
109
+ "prototype",
110
+ "hasOwnProperty",
111
+ "isPrototypeOf",
112
+ "propertyIsEnumerable",
113
+ "toString",
114
+ "valueOf",
115
+ "toLocaleString",
116
+ ]);
46
117
 
118
+ /**
119
+ * True when `name` is a syntactically valid JavaScript identifier of bounded
120
+ * length that does not collide with Object prototype keys.
121
+ *
122
+ * Rules:
123
+ * - non-empty string
124
+ * - length ≤ 64
125
+ * - matches /^[A-Za-z_$][\w$]*$/
126
+ * - not in NAME_BLACKLIST (prototype pollution guard)
127
+ *
128
+ * @param {unknown} name
129
+ * @returns {boolean}
130
+ */
47
131
  function isSafeName(name) {
48
132
  return (
49
- typeof name === "string" && name.length > 0 && !FORBIDDEN_KEYS.has(name)
133
+ typeof name === "string" &&
134
+ name.length > 0 &&
135
+ name.length <= NAME_MAX_LEN &&
136
+ NAME_RE.test(name) &&
137
+ !NAME_BLACKLIST.has(name)
50
138
  );
51
139
  }
52
140
 
53
141
  const SUB_PATH_SEGMENT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
54
142
  const SUB_PATH_RESERVED = new Set(["public", "_ws"]);
55
143
 
144
+ /**
145
+ * Validate a portal sub-path (the part served under `/fromcubes/<sub-path>`).
146
+ *
147
+ * Rules: non-empty, no leading/trailing slash, no whitespace, segments match
148
+ * `[a-zA-Z0-9][a-zA-Z0-9._-]*`, reserved segments `public` and `_ws` are
149
+ * blocked case-insensitively. Multiple slash-separated segments are allowed.
150
+ *
151
+ * @param {unknown} input
152
+ * @returns {SubPathResult}
153
+ */
56
154
  function validateSubPath(input) {
57
155
  if (typeof input !== "string") {
58
156
  return { ok: false, error: "Sub-path is required" };
@@ -94,6 +192,13 @@ function validateSubPath(input) {
94
192
  return { ok: true, value: trimmed };
95
193
  }
96
194
 
195
+ /**
196
+ * Build a PortalUser object from `x-portal-user-*` headers set by an upstream
197
+ * auth proxy. Returns null when no header is present.
198
+ *
199
+ * @param {Object<string,string>} headers HTTP request headers (lower-cased).
200
+ * @returns {PortalUser|null}
201
+ */
97
202
  function extractPortalUser(headers) {
98
203
  const user = {};
99
204
  if (headers["x-portal-user-id"]) user.userId = headers["x-portal-user-id"];
@@ -104,16 +209,32 @@ function extractPortalUser(headers) {
104
209
  if (headers["x-portal-user-email"])
105
210
  user.email = headers["x-portal-user-email"];
106
211
  if (headers["x-portal-user-role"]) user.role = headers["x-portal-user-role"];
107
- if (headers["x-portal-user-groups"]) {
108
- try {
109
- user.groups = JSON.parse(headers["x-portal-user-groups"]);
110
- } catch (_) {
111
- user.groups = headers["x-portal-user-groups"];
212
+ const groupsRaw = headers["x-portal-user-groups"];
213
+ if (typeof groupsRaw === "string" && groupsRaw.length > 0) {
214
+ if (groupsRaw.length > MAX_GROUPS_HEADER_BYTES) {
215
+ // Truncated → fall back to the raw string (safer than feeding multi-MB
216
+ // into JSON.parse). Auth proxy should never produce headers this large.
217
+ user.groups = groupsRaw.slice(0, MAX_GROUPS_HEADER_BYTES);
218
+ } else {
219
+ try {
220
+ user.groups = JSON.parse(groupsRaw);
221
+ } catch (_) {
222
+ user.groups = groupsRaw;
223
+ }
112
224
  }
113
225
  }
114
226
  return Object.keys(user).length > 0 ? user : null;
115
227
  }
116
228
 
229
+ /**
230
+ * Remove a single route mount-point from an Express router by exact path
231
+ * match. Used at deploy teardown to drop the previous portal's HTTP route
232
+ * before re-registering on the same path.
233
+ *
234
+ * @param {Object} router Express router (or Express app).
235
+ * @param {string} path Exact route path to remove (no glob/regex).
236
+ * @returns {void}
237
+ */
117
238
  function removeRoute(router, path) {
118
239
  if (!router || !router.stack) return;
119
240
  router.stack = router.stack.filter(
@@ -121,6 +242,31 @@ function removeRoute(router, path) {
121
242
  );
122
243
  }
123
244
 
245
+ /**
246
+ * @typedef {Object} EsbuildErrorLocation
247
+ * @property {number} line
248
+ */
249
+
250
+ /**
251
+ * @typedef {Object} EsbuildErrorEntry
252
+ * @property {string} text
253
+ * @property {EsbuildErrorLocation} [location]
254
+ */
255
+
256
+ /**
257
+ * @typedef {Object} EsbuildErrorLike
258
+ * @property {Array<EsbuildErrorEntry>} [errors]
259
+ * @property {string} [message]
260
+ */
261
+
262
+ /**
263
+ * Flatten an esbuild error object into a single multi-line string suitable
264
+ * for `node.error()` and the in-page error overlay. Includes line numbers
265
+ * when esbuild provides them; falls back to `.message` otherwise.
266
+ *
267
+ * @param {EsbuildErrorLike} e
268
+ * @returns {string}
269
+ */
124
270
  function formatEsbuildError(e) {
125
271
  return e.errors?.length
126
272
  ? e.errors
@@ -132,7 +278,15 @@ function formatEsbuildError(e) {
132
278
  : e.message;
133
279
  }
134
280
 
135
- // Fast JSX syntax validation (no bundling). Returns null on OK, error string on fail.
281
+ /**
282
+ * Fast JSX syntax validation via esbuild `transformSync` (no bundling, no
283
+ * filesystem lookup). Used at deploy time on individual component/utility
284
+ * snippets so the editor can attribute syntax errors to the offending node
285
+ * before any bundling pass runs on the portal.
286
+ *
287
+ * @param {string} jsx
288
+ * @returns {string|null} Multi-line error string, or null when JSX is syntactically valid.
289
+ */
136
290
  function quickCheckSyntax(jsx) {
137
291
  if (!jsx || !jsx.trim()) return null;
138
292
  try {
@@ -149,6 +303,90 @@ function quickCheckSyntax(jsx) {
149
303
  }
150
304
  }
151
305
 
306
+ // Identifiers that resolve at runtime through the bundler shim (React et al.)
307
+ // rather than through registry / utility / local-def lookup. Anything in this
308
+ // set is treated as "always satisfied" by findMissingComponentRefs.
309
+ const REACT_BUILTIN_TAGS = new Set([
310
+ "React",
311
+ "Fragment",
312
+ "Suspense",
313
+ "StrictMode",
314
+ "Profiler",
315
+ "ReactDOM",
316
+ "App",
317
+ ]);
318
+
319
+ const PASCAL_TAG_RE = /<\s*([A-Z][A-Za-z0-9_]*)/g;
320
+ const RE_ESCAPE = /[.*+?^${}()|[\]\\]/g;
321
+
322
+ /**
323
+ * Find PascalCase JSX tags in `userCode` that have no visible definition.
324
+ *
325
+ * Used at deploy time to catch the case where a portal references a shared
326
+ * component (e.g. `<Header/>`) without the example flow that defines it
327
+ * being imported into Node-RED. Without this check the bundler silently
328
+ * skips the missing name and the browser crashes with `ReferenceError`.
329
+ *
330
+ * A tag is considered satisfied when ANY of:
331
+ * - its name is in `knownNames` (component registry / utility symbols)
332
+ * - its name is a React built-in (`React`, `Fragment`, `Suspense`, …)
333
+ * - the bare identifier appears outside JSX-tag context in `userCode`
334
+ * (covers `import {Name} from …`, `function Name(){…}`, `const Name = …`,
335
+ * `class Name extends …`)
336
+ *
337
+ * @param {string} userCode
338
+ * @param {Set<string>} knownNames
339
+ * @returns {Set<string>} PascalCase names referenced in JSX with nothing
340
+ * to satisfy them.
341
+ */
342
+ function findMissingComponentRefs(userCode, knownNames) {
343
+ if (!userCode || typeof userCode !== "string") return new Set();
344
+ const known = knownNames instanceof Set ? knownNames : new Set();
345
+
346
+ PASCAL_TAG_RE.lastIndex = 0;
347
+ const used = new Set();
348
+ let m;
349
+ while ((m = PASCAL_TAG_RE.exec(userCode)) !== null) used.add(m[1]);
350
+
351
+ const missing = new Set();
352
+ for (const name of used) {
353
+ if (REACT_BUILTIN_TAGS.has(name)) continue;
354
+ if (known.has(name)) continue;
355
+ const escaped = name.replace(RE_ESCAPE, "\\$&");
356
+ const stripped = userCode.replace(
357
+ new RegExp(`<\\s*/?\\s*${escaped}\\b`, "g"),
358
+ "",
359
+ );
360
+ if (new RegExp(`\\b${escaped}\\b`).test(stripped)) continue;
361
+ missing.add(name);
362
+ }
363
+ return missing;
364
+ }
365
+
366
+ /**
367
+ * Resolve the content hash the server may advertise to a browser over the WS
368
+ * `version` frame. The hash MUST only be non-empty when there is a page the
369
+ * GET route can actually serve — otherwise a stale hash makes the served error
370
+ * / building page reload in a tight loop (it reloads on any non-empty
371
+ * `version` hash).
372
+ *
373
+ * States, in order:
374
+ * - no state / nulled `compiled` (post-`close` teardown window) → ""
375
+ * - degraded (build error but a previous good build is kept) → lastGood hash
376
+ * - hard build error with no fallback → ""
377
+ * - real serveable build (`compiled.js` present) → contentHash
378
+ *
379
+ * @param {?PageState} state pageState[endpoint] (may be null/partially torn down).
380
+ * @returns {string} Hash to advertise, or "" when nothing is serveable.
381
+ */
382
+ function serveableHash(state) {
383
+ if (!state || !state.compiled) return "";
384
+ if (state.compiled.error) {
385
+ return state.lastGood ? state.lastGood.contentHash || "" : "";
386
+ }
387
+ return state.compiled.js ? state.contentHash || "" : "";
388
+ }
389
+
152
390
  module.exports = function (RED) {
153
391
  return createHelpers(RED);
154
392
  };
@@ -157,15 +395,41 @@ module.exports.validateSubPath = validateSubPath;
157
395
  module.exports.isSafeName = isSafeName;
158
396
  module.exports.quickCheckSyntax = quickCheckSyntax;
159
397
  module.exports.formatEsbuildError = formatEsbuildError;
398
+ module.exports.extractPortalUser = extractPortalUser;
399
+ module.exports.findMissingComponentRefs = findMissingComponentRefs;
400
+ module.exports.serveableHash = serveableHash;
401
+ module.exports.NAME_MAX_LEN = NAME_MAX_LEN;
402
+ module.exports.MAX_GROUPS_HEADER_BYTES = MAX_GROUPS_HEADER_BYTES;
160
403
 
404
+ /**
405
+ * Factory that produces the runtime helper bag bound to a Node-RED instance.
406
+ * Returns the union of pure helpers (re-exported from module scope) and
407
+ * runtime helpers that need `RED` (disk cache, preInstall hook, transpile).
408
+ *
409
+ * @param {Object} RED Node-RED runtime object.
410
+ * @returns {Object} Helper bag — see the `return { … }` at the bottom of the function for the full surface.
411
+ */
161
412
  function createHelpers(RED) {
162
413
  // Package root — where react/react-dom live (this package's own node_modules)
163
414
  const pkgRoot = path.join(__dirname, "../..");
164
415
  // userDir — where dynamicModuleList installs user packages
165
416
  const userDir = RED.settings.userDir || path.join(__dirname, "../../../..");
166
417
 
167
- // Skip npm install for packages already present in node_modules (offline/Docker)
168
- RED.hooks.add("preInstall.fcPortal", (event) => {
418
+ /**
419
+ * `preInstall` hook (Node-RED 1.3+) vetoes an npm install when the
420
+ * package is already on disk. Useful for offline/Docker setups where
421
+ * Node-RED's auto-install pass would otherwise try to hit the registry
422
+ * and fail. Hook itself is *optional by design*: any unexpected error
423
+ * inside the body MUST NOT bubble up (it would cancel an install path
424
+ * the user actually expected to run). We log at `trace` level so the
425
+ * developer can opt into diagnostics via `logging.level = trace` in
426
+ * `settings.js`, without spamming default-level logs.
427
+ *
428
+ * @param {{dir: string, module: string}} event
429
+ * @returns {boolean|void} `false` skips the install; anything else proceeds.
430
+ * @listens RED.hooks#preInstall.portalReact
431
+ */
432
+ RED.hooks.add("preInstall.portalReact", (event) => {
169
433
  try {
170
434
  const modDir = path.join(event.dir, "node_modules", event.module);
171
435
  if (fs.existsSync(modDir)) {
@@ -174,13 +438,21 @@ function createHelpers(RED) {
174
438
  );
175
439
  return false;
176
440
  }
177
- } catch (_) {}
441
+ } catch (e) {
442
+ RED.log.trace("[portal-react] preInstall hook err: " + e.message);
443
+ }
178
444
  });
179
445
 
180
446
  // ── Disk cache for JS bundles and CSS ────────────────────────
181
447
  const cacheDir = path.join(userDir, "fromcubes", "cache");
182
448
  fs.mkdirSync(cacheDir, { recursive: true });
183
449
 
450
+ /**
451
+ * Load a cached compiled bundle from disk by JSX hash.
452
+ *
453
+ * @param {string} jsxHash
454
+ * @returns {?{js: string, metafile: ?Object, error: null}} null on cache miss.
455
+ */
184
456
  function readCachedJS(jsxHash) {
185
457
  try {
186
458
  const js = fs.readFileSync(path.join(cacheDir, jsxHash + ".js"), "utf8");
@@ -196,6 +468,14 @@ function createHelpers(RED) {
196
468
  }
197
469
  }
198
470
 
471
+ /**
472
+ * Persist a compiled bundle (and optional metafile) to disk under `<hash>.js`.
473
+ *
474
+ * @param {string} jsxHash
475
+ * @param {string} js
476
+ * @param {Object} [metafile] esbuild metafile for size analysis.
477
+ * @returns {void}
478
+ */
199
479
  function writeCachedJS(jsxHash, js, metafile) {
200
480
  try {
201
481
  fs.writeFileSync(path.join(cacheDir, jsxHash + ".js"), js, "utf8");
@@ -211,6 +491,12 @@ function createHelpers(RED) {
211
491
  }
212
492
  }
213
493
 
494
+ /**
495
+ * Load a cached Tailwind CSS bundle from disk by JSX hash.
496
+ *
497
+ * @param {string} jsxHash
498
+ * @returns {?{css: string, cssHash: string}} null on cache miss.
499
+ */
214
500
  function readCachedCSS(jsxHash) {
215
501
  try {
216
502
  const css = fs.readFileSync(
@@ -223,6 +509,13 @@ function createHelpers(RED) {
223
509
  }
224
510
  }
225
511
 
512
+ /**
513
+ * Persist a Tailwind CSS bundle to disk under `<hash>.css`.
514
+ *
515
+ * @param {string} jsxHash
516
+ * @param {string} css
517
+ * @returns {void}
518
+ */
226
519
  function writeCachedCSS(jsxHash, css) {
227
520
  try {
228
521
  fs.writeFileSync(path.join(cacheDir, jsxHash + ".css"), css, "utf8");
@@ -231,6 +524,13 @@ function createHelpers(RED) {
231
524
  }
232
525
  }
233
526
 
527
+ /**
528
+ * Remove cached `.js`, `.css`, and `.meta.json` files for a given hash.
529
+ * No-op when `jsxHash` is falsy. Errors are swallowed (best-effort cleanup).
530
+ *
531
+ * @param {?string} jsxHash
532
+ * @returns {void}
533
+ */
234
534
  function deleteCacheFiles(jsxHash) {
235
535
  if (!jsxHash) return;
236
536
  for (const ext of [".js", ".css", ".meta.json"]) {
@@ -240,6 +540,16 @@ function createHelpers(RED) {
240
540
  }
241
541
  }
242
542
 
543
+ /**
544
+ * True when any other portal endpoint currently relies on `jsxHash`.
545
+ * Used before `deleteCacheFiles` so a still-active sibling portal does not
546
+ * lose its cache when a different portal redeploys to a new hash.
547
+ *
548
+ * @param {string} jsxHash
549
+ * @param {Object<string, {jsxHash: string}>} pageState Endpoint → state.
550
+ * @param {string} excludeEndpoint Endpoint to skip in the scan (the one whose hash is being replaced).
551
+ * @returns {boolean}
552
+ */
243
553
  function isHashInUse(jsxHash, pageState, excludeEndpoint) {
244
554
  for (const ep in pageState) {
245
555
  if (ep !== excludeEndpoint && pageState[ep]?.jsxHash === jsxHash)
@@ -248,6 +558,16 @@ function createHelpers(RED) {
248
558
  return false;
249
559
  }
250
560
 
561
+ /**
562
+ * Bundle the user JSX (with utility/library/import code already concatenated)
563
+ * into a minified IIFE. Pre-validates with `quickCheckSyntax` first to avoid
564
+ * esbuild `buildSync` deadlocks on malformed input. The `react`/`react-dom`
565
+ * alias points at this package's own copies so peer-dep packages share a
566
+ * single React instance.
567
+ *
568
+ * @param {string} jsx
569
+ * @returns {TranspileResult}
570
+ */
251
571
  function transpile(jsx) {
252
572
  // Pre-validate with transformSync (fast, no bundling) to avoid esbuild buildSync deadlock on syntax errors
253
573
  const syntaxErr = quickCheckSyntax(jsx);
@@ -298,9 +618,11 @@ function createHelpers(RED) {
298
618
  quickCheckSyntax,
299
619
  generateCSS,
300
620
  extractPortalUser,
621
+ serveableHash,
301
622
  removeRoute,
302
623
  isSafeName,
303
624
  validateSubPath,
625
+ findMissingComponentRefs,
304
626
  pkgRoot,
305
627
  userDir,
306
628
  cacheDir,
@@ -22,7 +22,21 @@
22
22
 
23
23
  const PLUGIN_TYPE = "fromcubes-portal-react";
24
24
 
25
+ /**
26
+ * Build a plugin-hook dispatcher bound to `RED`.
27
+ *
28
+ * @param {Object} RED Node-RED runtime.
29
+ * @returns {{ allow: Function, transform: Function, hasHook: Function, PLUGIN_TYPE: string }}
30
+ */
25
31
  module.exports = function (RED) {
32
+ /**
33
+ * Collect hook functions for a given name across all registered plugins
34
+ * of type `PLUGIN_TYPE`. Returns an empty list when no plugins or when
35
+ * `RED.plugins` is unavailable.
36
+ *
37
+ * @param {string} name
38
+ * @returns {Array<{ fn: Function, id: string }>}
39
+ */
26
40
  function getHooks(name) {
27
41
  let plugins = [];
28
42
  try {
@@ -38,6 +52,14 @@ module.exports = function (RED) {
38
52
  return out;
39
53
  }
40
54
 
55
+ /**
56
+ * AND-fold across all registered hook implementations. A hook returning
57
+ * `false` (or throwing) blocks the action. No hooks → allowed.
58
+ *
59
+ * @param {string} name
60
+ * @param {...unknown} args
61
+ * @returns {boolean}
62
+ */
41
63
  function allow(name, ...args) {
42
64
  const hooks = getHooks(name);
43
65
  if (hooks.length === 0) return true;
@@ -56,6 +78,18 @@ module.exports = function (RED) {
56
78
  return true;
57
79
  }
58
80
 
81
+ /**
82
+ * Sequentially apply each hook to the running value. A hook returning
83
+ * `undefined` keeps the current value; returning `null` is treated as
84
+ * "drop this message" by callers (caller checks for null after transform).
85
+ * Throws are logged via `RED.log.error` and the hook is skipped.
86
+ *
87
+ * @template T
88
+ * @param {string} name
89
+ * @param {T} msg
90
+ * @param {...unknown} args
91
+ * @returns {T|null}
92
+ */
59
93
  function transform(name, msg, ...args) {
60
94
  const hooks = getHooks(name);
61
95
  let current = msg;
@@ -72,6 +106,10 @@ module.exports = function (RED) {
72
106
  return current;
73
107
  }
74
108
 
109
+ /**
110
+ * @param {string} name
111
+ * @returns {boolean} True when at least one plugin registers the hook.
112
+ */
75
113
  function hasHook(name) {
76
114
  return getHooks(name).length > 0;
77
115
  }