@aaqu/fromcubes-react 0.2.2 → 0.3.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.
- package/README.md +32 -1
- package/nodes/lib/helpers.js +20 -0
- package/nodes/lib/page-builder.js +57 -3
- package/nodes/lib/router.js +4 -1
- package/nodes/portal-react.html +20 -4
- package/nodes/portal-react.js +6 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -112,7 +112,8 @@ Everything your component needs from the flow lives in one hook:
|
|
|
112
112
|
|
|
113
113
|
```jsx
|
|
114
114
|
const {
|
|
115
|
-
data, // last
|
|
115
|
+
data, // last msg.payload delivered to this tab from the input wire (reactive)
|
|
116
|
+
topic, // msg.topic of that same message, "" when it had none
|
|
116
117
|
send, // send(payload, topic?) — emit msg on output wire
|
|
117
118
|
user, // portal user object (when Portal Auth is enabled), or null
|
|
118
119
|
portalClient, // unique session/tab ID assigned by server on connect
|
|
@@ -131,6 +132,36 @@ useEffect(() => { send({}, "init"); }, []);
|
|
|
131
132
|
|
|
132
133
|
In the flow, catch `topic === "init"`, look up the state (per-user via `msg._client.userId` if needed) and reply — `msg._client` is already set on the inbound message, so the response returns as a unicast to exactly that tab.
|
|
133
134
|
|
|
135
|
+
### Every message: `useNodeRedMessages()`
|
|
136
|
+
|
|
137
|
+
`data` and `topic` are React state: they hold only the **last** message. When two messages arrive before React renders — a flow answering two requests at once, a function node returning several msgs — React renders once and a `useEffect(..., [data])` sees only the last one; the earlier ones are silently lost. The same effect also does not re-fire when the same primitive payload arrives twice in a row.
|
|
138
|
+
|
|
139
|
+
For events and request → response protocols, use `useNodeRedMessages(handler)`. The handler is called once per incoming message, in order, as `(payload, { topic })`:
|
|
140
|
+
|
|
141
|
+
```jsx
|
|
142
|
+
function App() {
|
|
143
|
+
const { send } = useNodeRed();
|
|
144
|
+
const [photos, setPhotos] = useState([]);
|
|
145
|
+
const [saving, setSaving] = useState(false);
|
|
146
|
+
|
|
147
|
+
useNodeRedMessages((payload, { topic }) => {
|
|
148
|
+
if (topic === "photos") setPhotos(payload);
|
|
149
|
+
else if (topic === "saved") setSaving(false);
|
|
150
|
+
});
|
|
151
|
+
useEffect(() => { send({}, "init"); }, []);
|
|
152
|
+
// ...
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- It pairs with the `send({}, "init")` pattern above: the hook subscribes in its own effect, which runs before a later `useEffect` in the same component sends the request, so the reply cannot slip past it.
|
|
157
|
+
- No replay: a component mounted later does **not** get an earlier message (unlike `data`, which starts from the last payload this tab received).
|
|
158
|
+
- The latest `handler` is always the one called — no need to memoize it. It unsubscribes on unmount.
|
|
159
|
+
- A handler that throws is logged to the console; other handlers and `useNodeRed()` still get the message.
|
|
160
|
+
|
|
161
|
+
Rule of thumb: `data` to **show current state**, `useNodeRedMessages` for **events**. (Underneath is the low-level `window.__NR.subscribeMessages(fn)`.)
|
|
162
|
+
|
|
163
|
+
If a portal already ships its own `fc-portal-utility` named `useNodeRedMessages` (written before the built-in existed), that utility keeps working and shadows the built-in — in every portal that references the name, since utilities are shared.
|
|
164
|
+
|
|
134
165
|
## Node configuration
|
|
135
166
|
|
|
136
167
|
| Field | Purpose |
|
package/nodes/lib/helpers.js
CHANGED
|
@@ -141,6 +141,23 @@ function validateSubPath(input) {
|
|
|
141
141
|
return { ok: true, value: trimmed };
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The app's router: `app._router` on Express 4, `app.router` on Express 5.
|
|
146
|
+
* `_router` is read first because Express 4's `app.router` getter throws.
|
|
147
|
+
*
|
|
148
|
+
* @param {Object} app Express app (e.g. `RED.httpNode`).
|
|
149
|
+
* @returns {Object|undefined}
|
|
150
|
+
*/
|
|
151
|
+
function getHttpRouter(app) {
|
|
152
|
+
if (!app) return undefined;
|
|
153
|
+
if (app._router) return app._router;
|
|
154
|
+
try {
|
|
155
|
+
return app.router;
|
|
156
|
+
} catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
144
161
|
/**
|
|
145
162
|
* Remove a single route mount-point from an Express router by exact path
|
|
146
163
|
* match. Used at deploy teardown to drop the previous portal's HTTP route
|
|
@@ -745,6 +762,8 @@ module.exports.identifierRe = identifierRe;
|
|
|
745
762
|
module.exports.serveableHash = serveableHash;
|
|
746
763
|
module.exports.hasFreshBuild = hasFreshBuild;
|
|
747
764
|
module.exports.shouldSkipInstall = shouldSkipInstall;
|
|
765
|
+
module.exports.getHttpRouter = getHttpRouter;
|
|
766
|
+
module.exports.removeRoute = removeRoute;
|
|
748
767
|
module.exports.NAME_MAX_LEN = NAME_MAX_LEN;
|
|
749
768
|
|
|
750
769
|
/**
|
|
@@ -969,6 +988,7 @@ function createHelpers(RED) {
|
|
|
969
988
|
quickCheckSyntax,
|
|
970
989
|
serveableHash,
|
|
971
990
|
hasFreshBuild,
|
|
991
|
+
getHttpRouter,
|
|
972
992
|
removeRoute,
|
|
973
993
|
isSafeName,
|
|
974
994
|
validateSubPath,
|
|
@@ -205,7 +205,11 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
205
205
|
window.__NR = {
|
|
206
206
|
_ws: null,
|
|
207
207
|
_listeners: new Set(),
|
|
208
|
+
// Per-message listeners (useNodeRedMessages). Unlike _listeners they
|
|
209
|
+
// never get a replay of _lastData: each call is one real frame.
|
|
210
|
+
_msgListeners: new Set(),
|
|
208
211
|
_lastData: null,
|
|
212
|
+
_lastTopic: '',
|
|
209
213
|
_authRequired: false,
|
|
210
214
|
_authListeners: new Set(),
|
|
211
215
|
_retries: 0,
|
|
@@ -315,8 +319,17 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
315
319
|
}
|
|
316
320
|
}
|
|
317
321
|
if (m.type === 'data') {
|
|
322
|
+
const topic = typeof m.topic === 'string' ? m.topic : '';
|
|
318
323
|
this._lastData = m.payload;
|
|
319
|
-
this.
|
|
324
|
+
this._lastTopic = topic;
|
|
325
|
+
// Per-message listeners first, each isolated: React may
|
|
326
|
+
// coalesce the state updates below into one render, these
|
|
327
|
+
// see every frame.
|
|
328
|
+
const meta = { topic };
|
|
329
|
+
this._msgListeners.forEach(fn => {
|
|
330
|
+
try { fn(m.payload, meta); } catch (err) { console.error('useNodeRedMessages handler', err); }
|
|
331
|
+
});
|
|
332
|
+
this._listeners.forEach(fn => fn(m.payload, topic));
|
|
320
333
|
}
|
|
321
334
|
} catch (err) { console.error('WS parse', err); }
|
|
322
335
|
};
|
|
@@ -336,10 +349,15 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
|
|
|
336
349
|
|
|
337
350
|
subscribe(fn) {
|
|
338
351
|
this._listeners.add(fn);
|
|
339
|
-
if (this._lastData !== null) fn(this._lastData);
|
|
352
|
+
if (this._lastData !== null) fn(this._lastData, this._lastTopic);
|
|
340
353
|
return () => this._listeners.delete(fn);
|
|
341
354
|
},
|
|
342
355
|
|
|
356
|
+
subscribeMessages(fn) {
|
|
357
|
+
this._msgListeners.add(fn);
|
|
358
|
+
return () => this._msgListeners.delete(fn);
|
|
359
|
+
},
|
|
360
|
+
|
|
343
361
|
subscribeAuth(fn) {
|
|
344
362
|
this._authListeners.add(fn);
|
|
345
363
|
if (this._authRequired) fn(true);
|
|
@@ -496,4 +514,40 @@ function buildErrorPage(title, error, wsPath) {
|
|
|
496
514
|
</html>`;
|
|
497
515
|
}
|
|
498
516
|
|
|
499
|
-
|
|
517
|
+
/**
|
|
518
|
+
* The hooks every portal bundle gets, inserted after the React shorthand and
|
|
519
|
+
* before utilities (so a utility may call them). Browser code, kept here next
|
|
520
|
+
* to the `window.__NR` bridge it talks to.
|
|
521
|
+
*
|
|
522
|
+
* - `useNodeRed()` — `data`/`topic` are STATE: the last frame (one object,
|
|
523
|
+
* so the two never come from different frames). Two frames arriving
|
|
524
|
+
* before React renders collapse into one render, and an effect on `[data]`
|
|
525
|
+
* sees only the second.
|
|
526
|
+
* - `useNodeRedMessages(handler)` — one call per frame, `(payload, {topic})`,
|
|
527
|
+
* no replay of an earlier frame on mount. Assigned to `window` rather than
|
|
528
|
+
* declared: a portal that ships its own utility of that name (written before
|
|
529
|
+
* this existed) shadows it instead of colliding with it at bundle time.
|
|
530
|
+
*/
|
|
531
|
+
const NR_HOOKS_SRC = [
|
|
532
|
+
"function useNodeRed() {",
|
|
533
|
+
" const [last, setLast] = React.useState(() => ({ data: window.__NR._lastData, topic: window.__NR._lastTopic }));",
|
|
534
|
+
" React.useEffect(() => window.__NR.subscribe((data, topic) => setLast({ data, topic: topic || '' })), []);",
|
|
535
|
+
" const [authRequired, setAuthRequired] = React.useState(window.__NR._authRequired);",
|
|
536
|
+
" React.useEffect(() => window.__NR.subscribeAuth(setAuthRequired), []);",
|
|
537
|
+
" const send = React.useCallback((payload, topic) => {",
|
|
538
|
+
" window.__NR.send(payload, topic);",
|
|
539
|
+
" }, []);",
|
|
540
|
+
" const user = window.__NR._user || null;",
|
|
541
|
+
" const portalClient = window.__NR._portalClient;",
|
|
542
|
+
" return { data: last.data, topic: last.topic, send, user, portalClient, authRequired };",
|
|
543
|
+
"}",
|
|
544
|
+
"window.useNodeRedMessages = function useNodeRedMessages(handler) {",
|
|
545
|
+
" const ref = React.useRef(handler);",
|
|
546
|
+
" React.useLayoutEffect(() => { ref.current = handler; });",
|
|
547
|
+
" React.useEffect(() => window.__NR.subscribeMessages((payload, meta) => {",
|
|
548
|
+
" if (typeof ref.current === 'function') ref.current(payload, meta);",
|
|
549
|
+
" }), []);",
|
|
550
|
+
"};",
|
|
551
|
+
].join("\n");
|
|
552
|
+
|
|
553
|
+
module.exports = { buildPage, buildErrorPage, NR_HOOKS_SRC };
|
package/nodes/lib/router.js
CHANGED
|
@@ -44,7 +44,10 @@
|
|
|
44
44
|
function route(msg, ctx) {
|
|
45
45
|
const { clients, userIndex, sendTo } = ctx;
|
|
46
46
|
const target = msg && msg._client;
|
|
47
|
-
|
|
47
|
+
// topic rides along only when it is a non-empty string — useNodeRed()
|
|
48
|
+
// ignores it, useNodeRedMessages() hands it to the handler.
|
|
49
|
+
const topic = msg && typeof msg.topic === "string" && msg.topic ? msg.topic : undefined;
|
|
50
|
+
const frame = JSON.stringify({ type: "data", payload: msg.payload, topic });
|
|
48
51
|
|
|
49
52
|
let delivered = 0;
|
|
50
53
|
|
package/nodes/portal-react.html
CHANGED
|
@@ -130,7 +130,8 @@
|
|
|
130
130
|
const libContent = [
|
|
131
131
|
"declare const React: any;",
|
|
132
132
|
"declare const ReactDOM: any;",
|
|
133
|
-
"declare function useNodeRed(): { data: any; send: (payload: any, topic?: string) => void; user: { userId: string; login?: string; displayName?: string; email?: string; role?: string; groups: string[] } | null; portalClient: string | null; authRequired: boolean };",
|
|
133
|
+
"declare function useNodeRed(): { data: any; topic: string; send: (payload: any, topic?: string) => void; user: { userId: string; login?: string; displayName?: string; email?: string; role?: string; groups: string[] } | null; portalClient: string | null; authRequired: boolean };",
|
|
134
|
+
"declare function useNodeRedMessages(handler: (payload: any, meta: { topic: string }) => void): void;",
|
|
134
135
|
].join("\n");
|
|
135
136
|
console.log(PREFIX, "addExtraLib globals.d.ts");
|
|
136
137
|
jsDef.addExtraLib(libContent, "file:///globals.d.ts");
|
|
@@ -2071,9 +2072,10 @@
|
|
|
2071
2072
|
let headEditorInstance = null;
|
|
2072
2073
|
|
|
2073
2074
|
const STARTER = [
|
|
2074
|
-
"// useNodeRed() \u2192 { data, send, portalClient }",
|
|
2075
|
-
"// data = last msg.payload from input wire",
|
|
2075
|
+
"// useNodeRed() \u2192 { data, topic, send, portalClient }",
|
|
2076
|
+
"// data = last msg.payload from input wire (topic = its msg.topic)",
|
|
2076
2077
|
"// send(payload, topic?) = push msg to output wire",
|
|
2078
|
+
"// useNodeRedMessages((payload, { topic }) => ...) = every msg, in order",
|
|
2077
2079
|
"// Components from fc-portal-component nodes are available by name.",
|
|
2078
2080
|
"",
|
|
2079
2081
|
"function App() {",
|
|
@@ -2719,6 +2721,8 @@
|
|
|
2719
2721
|
<dl class="message-properties">
|
|
2720
2722
|
<dt>payload <span class="property-type">any</span></dt>
|
|
2721
2723
|
<dd>Sent to the open pages; read it with <code>useNodeRed().data</code>.</dd>
|
|
2724
|
+
<dt class="optional">topic <span class="property-type">string</span></dt>
|
|
2725
|
+
<dd>Sent along with the payload; read it with <code>useNodeRed().topic</code>.</dd>
|
|
2722
2726
|
<dt class="optional">_client <span class="property-type">object</span></dt>
|
|
2723
2727
|
<dd>
|
|
2724
2728
|
Who gets it: <code>{ portalClient }</code> one browser tab,
|
|
@@ -2747,10 +2751,22 @@
|
|
|
2747
2751
|
}</pre>
|
|
2748
2752
|
<p>
|
|
2749
2753
|
<code>useNodeRed()</code> gives <code>data</code> (the last payload,
|
|
2750
|
-
<code>null</code> until one arrives), <code>
|
|
2754
|
+
<code>null</code> until one arrives), <code>topic</code> (that message's
|
|
2755
|
+
topic, <code>""</code> if none), <code>send</code>, <code>user</code>
|
|
2751
2756
|
(the visitor, or <code>null</code>) and <code>portalClient</code> (this
|
|
2752
2757
|
tab's id).
|
|
2753
2758
|
</p>
|
|
2759
|
+
<p>
|
|
2760
|
+
<code>data</code> holds only the latest payload: messages that arrive
|
|
2761
|
+
faster than React renders are merged into one render. To handle every
|
|
2762
|
+
message (events, replies to a request), use
|
|
2763
|
+
<code>useNodeRedMessages</code> — it calls your function once per
|
|
2764
|
+
message, in order:
|
|
2765
|
+
</p>
|
|
2766
|
+
<pre>useNodeRedMessages((payload, { topic }) => {
|
|
2767
|
+
if (topic === "photos") setPhotos(payload);
|
|
2768
|
+
else if (topic === "saved") setSaving(false);
|
|
2769
|
+
});</pre>
|
|
2754
2770
|
<p>
|
|
2755
2771
|
<b>React Component</b> and <b>React Utility</b> nodes can be used here by
|
|
2756
2772
|
name, without imports.
|
package/nodes/portal-react.js
CHANGED
|
@@ -381,6 +381,7 @@ module.exports = function (RED) {
|
|
|
381
381
|
quickCheckSyntax,
|
|
382
382
|
serveableHash,
|
|
383
383
|
hasFreshBuild,
|
|
384
|
+
getHttpRouter,
|
|
384
385
|
removeRoute,
|
|
385
386
|
isSafeName,
|
|
386
387
|
validateSubPath,
|
|
@@ -425,7 +426,7 @@ module.exports = function (RED) {
|
|
|
425
426
|
for (const nodeId of Object.keys(rebuildCallbacks)) scheduleRebuildSelf(nodeId);
|
|
426
427
|
}
|
|
427
428
|
}
|
|
428
|
-
const { buildPage, buildErrorPage } = require("./lib/page-builder");
|
|
429
|
+
const { buildPage, buildErrorPage, NR_HOOKS_SRC } = require("./lib/page-builder");
|
|
429
430
|
const { createPortalPageHandler } = require("./lib/portal-page-route");
|
|
430
431
|
const {
|
|
431
432
|
extractUtilitySymbols,
|
|
@@ -539,7 +540,7 @@ module.exports = function (RED) {
|
|
|
539
540
|
deleteCacheFiles(oldSt.jsxHash);
|
|
540
541
|
}
|
|
541
542
|
delete pageState[prevEndpoint];
|
|
542
|
-
removeRoute(RED.httpNode
|
|
543
|
+
removeRoute(getHttpRouter(RED.httpNode), prevEndpoint);
|
|
543
544
|
delete registeredRoutes[prevEndpoint];
|
|
544
545
|
if (endpointOwners[prevEndpoint] === nodeId) {
|
|
545
546
|
delete endpointOwners[prevEndpoint];
|
|
@@ -878,21 +879,8 @@ module.exports = function (RED) {
|
|
|
878
879
|
"Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
|
|
879
880
|
"const { createContext, memo, forwardRef, Fragment } = React;",
|
|
880
881
|
"",
|
|
881
|
-
"// ── useNodeRed
|
|
882
|
-
|
|
883
|
-
"function useNodeRed() {",
|
|
884
|
-
" const [data, setData] = React.useState(window.__NR._lastData);",
|
|
885
|
-
" React.useEffect(() => window.__NR.subscribe(setData), []);",
|
|
886
|
-
" const [authRequired, setAuthRequired] = React.useState(window.__NR._authRequired);",
|
|
887
|
-
" React.useEffect(() => window.__NR.subscribeAuth(setAuthRequired), []);",
|
|
888
|
-
" const send = React.useCallback((payload, topic) => {",
|
|
889
|
-
" window.__NR.send(payload, topic);",
|
|
890
|
-
" }, []);",
|
|
891
|
-
" const user = window.__NR._user || null;",
|
|
892
|
-
" const portalClient = window.__NR._portalClient;",
|
|
893
|
-
" return { data, send, user, portalClient, authRequired };",
|
|
894
|
-
"}",
|
|
895
|
-
].join("\n"),
|
|
882
|
+
"// ── useNodeRed hooks ──",
|
|
883
|
+
NR_HOOKS_SRC,
|
|
896
884
|
"",
|
|
897
885
|
"// ── Utilities (helpers / hooks / constants) ──",
|
|
898
886
|
cleanUtilJsx,
|
|
@@ -1596,7 +1584,7 @@ module.exports = function (RED) {
|
|
|
1596
1584
|
deleteCacheFiles(lastJsxHash);
|
|
1597
1585
|
}
|
|
1598
1586
|
delete pageState[endpoint];
|
|
1599
|
-
removeRoute(RED.httpNode
|
|
1587
|
+
removeRoute(getHttpRouter(RED.httpNode), endpoint);
|
|
1600
1588
|
delete registeredRoutes[endpoint];
|
|
1601
1589
|
}
|
|
1602
1590
|
|