@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 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 broadcast msg.payload from input wire (reactive)
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 |
@@ -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._listeners.forEach(fn => fn(m.payload));
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
- module.exports = { buildPage, buildErrorPage };
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 };
@@ -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
- const frame = JSON.stringify({ type: "data", payload: msg.payload });
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
 
@@ -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>send</code>, <code>user</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 }) =&gt; {
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.
@@ -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._router, prevEndpoint);
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 hook ──",
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._router, endpoint);
1587
+ removeRoute(getHttpRouter(RED.httpNode), endpoint);
1600
1588
  delete registeredRoutes[endpoint];
1601
1589
  }
1602
1590
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaqu/fromcubes-react",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Fromcubes React - React portals for Node-RED, built at deploy, with live WebSocket data binding",
5
5
  "type": "commonjs",
6
6
  "keywords": [