@aaqu/fromcubes-portal-react 0.1.0-alpha.21 → 0.1.0-alpha.23
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 +91 -9
- package/examples/D3 Poland Map.json +90 -0
- package/examples/Live Chart.json +103 -0
- package/examples/PixiJS Sprites.json +118 -0
- package/examples/Sensor Portal.json +97 -0
- package/examples/Shared Components.json +78 -0
- package/examples/Three.js Scene.json +122 -0
- package/examples/Utility Hooks.json +96 -0
- package/examples/WebGPU Shader.json +114 -0
- package/nodes/lib/assets.js +141 -11
- package/nodes/lib/helpers.js +308 -12
- package/nodes/lib/hooks.js +38 -0
- package/nodes/lib/page-builder.js +145 -32
- package/nodes/lib/router.js +25 -1
- package/nodes/portal-react.html +826 -218
- package/nodes/portal-react.js +1193 -180
- package/package.json +20 -5
- package/examples/001-shared-components-flow.json +0 -68
- package/examples/002-sensor-portal-flow.json +0 -71
- package/examples/003-chart-portal-flow.json +0 -93
- package/examples/004-d3-poland-flow.json +0 -80
- package/examples/005-threejs-portal-flow.json +0 -87
- package/examples/006-pixi-portal-flow.json +0 -86
- package/examples/007-webgpu-tsl-flow.json +0 -85
package/nodes/lib/helpers.js
CHANGED
|
@@ -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
|
+
|
|
10
|
+
/**
|
|
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
|
+
|
|
1
17
|
/**
|
|
2
|
-
*
|
|
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
|
-
|
|
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" &&
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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,66 @@ 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
|
+
|
|
152
366
|
module.exports = function (RED) {
|
|
153
367
|
return createHelpers(RED);
|
|
154
368
|
};
|
|
@@ -157,15 +371,40 @@ module.exports.validateSubPath = validateSubPath;
|
|
|
157
371
|
module.exports.isSafeName = isSafeName;
|
|
158
372
|
module.exports.quickCheckSyntax = quickCheckSyntax;
|
|
159
373
|
module.exports.formatEsbuildError = formatEsbuildError;
|
|
374
|
+
module.exports.extractPortalUser = extractPortalUser;
|
|
375
|
+
module.exports.findMissingComponentRefs = findMissingComponentRefs;
|
|
376
|
+
module.exports.NAME_MAX_LEN = NAME_MAX_LEN;
|
|
377
|
+
module.exports.MAX_GROUPS_HEADER_BYTES = MAX_GROUPS_HEADER_BYTES;
|
|
160
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Factory that produces the runtime helper bag bound to a Node-RED instance.
|
|
381
|
+
* Returns the union of pure helpers (re-exported from module scope) and
|
|
382
|
+
* runtime helpers that need `RED` (disk cache, preInstall hook, transpile).
|
|
383
|
+
*
|
|
384
|
+
* @param {Object} RED Node-RED runtime object.
|
|
385
|
+
* @returns {Object} Helper bag — see the `return { … }` at the bottom of the function for the full surface.
|
|
386
|
+
*/
|
|
161
387
|
function createHelpers(RED) {
|
|
162
388
|
// Package root — where react/react-dom live (this package's own node_modules)
|
|
163
389
|
const pkgRoot = path.join(__dirname, "../..");
|
|
164
390
|
// userDir — where dynamicModuleList installs user packages
|
|
165
391
|
const userDir = RED.settings.userDir || path.join(__dirname, "../../../..");
|
|
166
392
|
|
|
167
|
-
|
|
168
|
-
|
|
393
|
+
/**
|
|
394
|
+
* `preInstall` hook (Node-RED 1.3+) — vetoes an npm install when the
|
|
395
|
+
* package is already on disk. Useful for offline/Docker setups where
|
|
396
|
+
* Node-RED's auto-install pass would otherwise try to hit the registry
|
|
397
|
+
* and fail. Hook itself is *optional by design*: any unexpected error
|
|
398
|
+
* inside the body MUST NOT bubble up (it would cancel an install path
|
|
399
|
+
* the user actually expected to run). We log at `trace` level so the
|
|
400
|
+
* developer can opt into diagnostics via `logging.level = trace` in
|
|
401
|
+
* `settings.js`, without spamming default-level logs.
|
|
402
|
+
*
|
|
403
|
+
* @param {{dir: string, module: string}} event
|
|
404
|
+
* @returns {boolean|void} `false` skips the install; anything else proceeds.
|
|
405
|
+
* @listens RED.hooks#preInstall.portalReact
|
|
406
|
+
*/
|
|
407
|
+
RED.hooks.add("preInstall.portalReact", (event) => {
|
|
169
408
|
try {
|
|
170
409
|
const modDir = path.join(event.dir, "node_modules", event.module);
|
|
171
410
|
if (fs.existsSync(modDir)) {
|
|
@@ -174,13 +413,21 @@ function createHelpers(RED) {
|
|
|
174
413
|
);
|
|
175
414
|
return false;
|
|
176
415
|
}
|
|
177
|
-
} catch (
|
|
416
|
+
} catch (e) {
|
|
417
|
+
RED.log.trace("[portal-react] preInstall hook err: " + e.message);
|
|
418
|
+
}
|
|
178
419
|
});
|
|
179
420
|
|
|
180
421
|
// ── Disk cache for JS bundles and CSS ────────────────────────
|
|
181
422
|
const cacheDir = path.join(userDir, "fromcubes", "cache");
|
|
182
423
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
183
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Load a cached compiled bundle from disk by JSX hash.
|
|
427
|
+
*
|
|
428
|
+
* @param {string} jsxHash
|
|
429
|
+
* @returns {?{js: string, metafile: ?Object, error: null}} null on cache miss.
|
|
430
|
+
*/
|
|
184
431
|
function readCachedJS(jsxHash) {
|
|
185
432
|
try {
|
|
186
433
|
const js = fs.readFileSync(path.join(cacheDir, jsxHash + ".js"), "utf8");
|
|
@@ -196,6 +443,14 @@ function createHelpers(RED) {
|
|
|
196
443
|
}
|
|
197
444
|
}
|
|
198
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Persist a compiled bundle (and optional metafile) to disk under `<hash>.js`.
|
|
448
|
+
*
|
|
449
|
+
* @param {string} jsxHash
|
|
450
|
+
* @param {string} js
|
|
451
|
+
* @param {Object} [metafile] esbuild metafile for size analysis.
|
|
452
|
+
* @returns {void}
|
|
453
|
+
*/
|
|
199
454
|
function writeCachedJS(jsxHash, js, metafile) {
|
|
200
455
|
try {
|
|
201
456
|
fs.writeFileSync(path.join(cacheDir, jsxHash + ".js"), js, "utf8");
|
|
@@ -211,6 +466,12 @@ function createHelpers(RED) {
|
|
|
211
466
|
}
|
|
212
467
|
}
|
|
213
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Load a cached Tailwind CSS bundle from disk by JSX hash.
|
|
471
|
+
*
|
|
472
|
+
* @param {string} jsxHash
|
|
473
|
+
* @returns {?{css: string, cssHash: string}} null on cache miss.
|
|
474
|
+
*/
|
|
214
475
|
function readCachedCSS(jsxHash) {
|
|
215
476
|
try {
|
|
216
477
|
const css = fs.readFileSync(
|
|
@@ -223,6 +484,13 @@ function createHelpers(RED) {
|
|
|
223
484
|
}
|
|
224
485
|
}
|
|
225
486
|
|
|
487
|
+
/**
|
|
488
|
+
* Persist a Tailwind CSS bundle to disk under `<hash>.css`.
|
|
489
|
+
*
|
|
490
|
+
* @param {string} jsxHash
|
|
491
|
+
* @param {string} css
|
|
492
|
+
* @returns {void}
|
|
493
|
+
*/
|
|
226
494
|
function writeCachedCSS(jsxHash, css) {
|
|
227
495
|
try {
|
|
228
496
|
fs.writeFileSync(path.join(cacheDir, jsxHash + ".css"), css, "utf8");
|
|
@@ -231,6 +499,13 @@ function createHelpers(RED) {
|
|
|
231
499
|
}
|
|
232
500
|
}
|
|
233
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Remove cached `.js`, `.css`, and `.meta.json` files for a given hash.
|
|
504
|
+
* No-op when `jsxHash` is falsy. Errors are swallowed (best-effort cleanup).
|
|
505
|
+
*
|
|
506
|
+
* @param {?string} jsxHash
|
|
507
|
+
* @returns {void}
|
|
508
|
+
*/
|
|
234
509
|
function deleteCacheFiles(jsxHash) {
|
|
235
510
|
if (!jsxHash) return;
|
|
236
511
|
for (const ext of [".js", ".css", ".meta.json"]) {
|
|
@@ -240,6 +515,16 @@ function createHelpers(RED) {
|
|
|
240
515
|
}
|
|
241
516
|
}
|
|
242
517
|
|
|
518
|
+
/**
|
|
519
|
+
* True when any other portal endpoint currently relies on `jsxHash`.
|
|
520
|
+
* Used before `deleteCacheFiles` so a still-active sibling portal does not
|
|
521
|
+
* lose its cache when a different portal redeploys to a new hash.
|
|
522
|
+
*
|
|
523
|
+
* @param {string} jsxHash
|
|
524
|
+
* @param {Object<string, {jsxHash: string}>} pageState Endpoint → state.
|
|
525
|
+
* @param {string} excludeEndpoint Endpoint to skip in the scan (the one whose hash is being replaced).
|
|
526
|
+
* @returns {boolean}
|
|
527
|
+
*/
|
|
243
528
|
function isHashInUse(jsxHash, pageState, excludeEndpoint) {
|
|
244
529
|
for (const ep in pageState) {
|
|
245
530
|
if (ep !== excludeEndpoint && pageState[ep]?.jsxHash === jsxHash)
|
|
@@ -248,6 +533,16 @@ function createHelpers(RED) {
|
|
|
248
533
|
return false;
|
|
249
534
|
}
|
|
250
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Bundle the user JSX (with utility/library/import code already concatenated)
|
|
538
|
+
* into a minified IIFE. Pre-validates with `quickCheckSyntax` first to avoid
|
|
539
|
+
* esbuild `buildSync` deadlocks on malformed input. The `react`/`react-dom`
|
|
540
|
+
* alias points at this package's own copies so peer-dep packages share a
|
|
541
|
+
* single React instance.
|
|
542
|
+
*
|
|
543
|
+
* @param {string} jsx
|
|
544
|
+
* @returns {TranspileResult}
|
|
545
|
+
*/
|
|
251
546
|
function transpile(jsx) {
|
|
252
547
|
// Pre-validate with transformSync (fast, no bundling) to avoid esbuild buildSync deadlock on syntax errors
|
|
253
548
|
const syntaxErr = quickCheckSyntax(jsx);
|
|
@@ -301,6 +596,7 @@ function createHelpers(RED) {
|
|
|
301
596
|
removeRoute,
|
|
302
597
|
isSafeName,
|
|
303
598
|
validateSubPath,
|
|
599
|
+
findMissingComponentRefs,
|
|
304
600
|
pkgRoot,
|
|
305
601
|
userDir,
|
|
306
602
|
cacheDir,
|
package/nodes/lib/hooks.js
CHANGED
|
@@ -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
|
}
|