@aaqu/fromcubes-portal-react 0.1.0-alpha.15 → 0.1.0-alpha.17

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
@@ -1,41 +1,12 @@
1
1
  # @aaqu/fromcubes-portal-react
2
2
 
3
- > **⚠️ Alpha Module** — This project is in early development. Expect many breaking changes. Please test on a clean Node-RED instance.
3
+ > **⚠️ Alpha Module** — This project is in early development. Expect breaking changes. Test on a clean Node-RED instance.
4
4
 
5
- React portal node for Node-RED. Server-side JSX transpilation via esbuild. Tailwind CSS 4. Zero runtime compilation in browser.
5
+ A Node-RED node that turns any `/fromcubes/<sub-path>` URL into a React page. Write JSX in the editor, deploy, open the URL — your component talks to the flow over WebSocket. No build step, no browser compiler. All portal pages are served under the hardcoded `/fromcubes/` prefix so every node cleanly coexists under one URL tree.
6
6
 
7
- [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/L4L01UOFRG)
8
-
9
- ## How it works
7
+ For internals, plugin authoring, and the deploy pipeline see [README-DEV.md](./README-DEV.md).
10
8
 
11
- ```
12
- ┌─ Deploy time (Node-RED server) ───────────────────────────┐
13
- │ │
14
- │ npm packages ──► auto-installed at deploy │
15
- │ (d3, three, @react-three/fiber…) │
16
- │ via dynamicModuleList │
17
- │ │
18
- │ React + packages + JSX ──► single esbuild pass │
19
- │ one IIFE, one React instance (alias) │
20
- │ tree-shaking removes unused exports │
21
- │ React peer deps share same instance │
22
- │ │
23
- │ Tailwind classes ──► server-side compile ──► CSS │
24
- │ stored per-page in pageState │
25
- │ │
26
- │ Unchanged JSX on redeploy = reuse CSS, 0ms │
27
- │ Changed JSX = retranspile, ~5ms │
28
- └───────────────────────────────────────────────────────────┘
29
-
30
- ┌─ Runtime (browser) ───────────────────────────────────────┐
31
- │ │
32
- │ GET /endpoint ──► HTML + single inlined JS bundle │
33
- │ Tailwind CSS (server-compiled) │
34
- │ NO Babel, NO Sucrase, NO compiler │
35
- │ │
36
- │ WebSocket /endpoint/_ws ◄──► Node-RED msg I/O │
37
- └───────────────────────────────────────────────────────────┘
38
- ```
9
+ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/L4L01UOFRG)
39
10
 
40
11
  ## Install
41
12
 
@@ -45,57 +16,92 @@ npm install @aaqu/fromcubes-portal-react@alpha
45
16
  # restart Node-RED
46
17
  ```
47
18
 
48
- Dependencies install automatically. No build step needed.
19
+ That's it. No build step. Any npm packages you list in the node config (e.g. `d3, three`) are installed automatically on deploy.
20
+
21
+ ## Your first portal
22
+
23
+ 1. Drop a **portal-react** node onto a flow.
24
+ 2. Set **Sub-path** to e.g. `hello` (the node will serve at `/fromcubes/hello`; the `/fromcubes/` prefix is fixed).
25
+ 3. Open the code editor and paste:
26
+ ```jsx
27
+ function App() {
28
+ const { data, send } = useNodeRed();
29
+ return (
30
+ <div className="p-4">
31
+ <p>From flow: {JSON.stringify(data)}</p>
32
+ <button
33
+ className="px-3 py-1 bg-blue-500 text-white rounded"
34
+ onClick={() => send({ clicked: Date.now() })}
35
+ >
36
+ click me
37
+ </button>
38
+ </div>
39
+ );
40
+ }
41
+ ```
42
+ 4. Deploy. Open `http://localhost:1880/fromcubes/hello`.
43
+ 5. Wire an **inject** node into the portal-react input → see `data` update live. Wire its output → see button clicks arrive in your flow.
44
+
45
+ ## The `useNodeRed()` hook
46
+
47
+ Everything your component needs from the flow lives in one hook:
49
48
 
50
- ## npm scripts
49
+ ```jsx
50
+ const {
51
+ data, // last broadcast msg.payload from input wire (reactive)
52
+ send, // send(payload, topic?) — emit msg on output wire
53
+ user, // portal user object (when Portal Auth is enabled), or null
54
+ portalClient, // unique session/tab ID assigned by server on connect
55
+ } = useNodeRed();
56
+ ```
51
57
 
52
- | Script | Purpose |
53
- |---|---|
54
- | `npm start` | Start Node-RED |
58
+ ### Recovery on connect
55
59
 
56
- ## Nodes
60
+ A freshly-connected client receives the **last broadcast payload** the server has cached for this endpoint, sent as a distinct `recovery` frame. By default it's seeded straight into `data`, so the first render of a new tab shows the most recent value instead of waiting for the next broadcast — same idea as dashboard2's `lastMsg`.
57
61
 
58
- ### portal-react
62
+ Opt out per page:
59
63
 
60
- | Field | Purpose |
61
- |---|------------------------------------------------------------------|
62
- | Endpoint | HTTP path, e.g. `/fromcubes/page1` |
63
- | Page Title | Browser tab title |
64
- | npm Packages | Comma-separated packages, e.g. `d3, three, @react-three/fiber` |
65
- | Portal Auth | Enable portal user header extraction |
66
- | Head HTML | Extra `<head>` tags (CDN, fonts, CSS) |
67
- | Code Editor | Monaco with JSX — must define `<App />` |
64
+ ```jsx
65
+ // data stays undefined until a fresh broadcast arrives — no recovery seed
66
+ const { data } = useNodeRed({ ignoreRecovery: true });
67
+ ```
68
68
 
69
- ### fc-portal-component (config node)
69
+ The opt-out is page-wide — the strictest call wins. If any component on the page asks to ignore recovery, recovery is dropped for all of them.
70
70
 
71
- Shared component store. Each component has name, code, input/output field definitions.
72
- Referenced components (with transitive dependencies) are selectively injected at transpile time.
71
+ ## Node configuration
72
+
73
+ | Field | Purpose |
74
+ |---|---|
75
+ | Sub-path | Part after `/fromcubes/`, e.g. `page1` → served at `/fromcubes/page1`. Required. Nesting allowed (`team/alpha`). Reserved: `public`, `_ws`. |
76
+ | Page Title | Browser tab title |
77
+ | npm Packages | Comma-separated, e.g. `d3, three, @react-three/fiber` |
78
+ | Portal Auth | Enable portal user header extraction (see Multi-user) |
79
+ | Head HTML | Extra `<head>` tags (CDN, fonts, CSS) |
80
+ | Code Editor | Monaco with JSX — must define `<App />` |
81
+
82
+ There is also a config node, **fc-portal-component**, that lets you define reusable React components once and reference them by name from any portal-react node. Referenced components (and their transitive dependencies) are injected at transpile time, so unused ones add nothing to the bundle.
73
83
 
74
84
  ## Editor features
75
85
 
76
- - **Monaco editor** with full JSX support and `useNodeRed()` type declarations
77
- - **Tailwind CSS autocompletion** inside `className="..."` strings (~19k utility classes)
78
- - **JSX tag completion** — type tag name, Tab to expand (open+close and self-closing variants)
86
+ - **Monaco** with full JSX support and `useNodeRed()` type declarations
87
+ - **Tailwind CSS autocompletion** inside `className="..."` (~19k utility classes)
88
+ - **JSX tag completion** — type tag name, Tab to expand
79
89
  - **Self-close collapse** — type `/` inside empty `<tag></tag>` to convert to `<tag />`
80
90
  - **Component completion** — registry components + any PascalCase word
81
- - **Portal Assets sidebar** — file manager for static assets (GLB models, textures, fonts, etc.)
91
+ - **Portal Assets sidebar** — file manager for static assets (GLB, textures, fonts)
82
92
 
83
- ## Hook API
93
+ ## Multi-user / Multi-tenancy
84
94
 
85
- ```jsx
86
- function App() {
87
- const { data, send, user, portalClient } = useNodeRed();
88
- // data = last msg.payload from input wire (reactive)
89
- // send(payload, topic?) = emit msg on output wire
90
- // user = portal user object (when Portal Auth enabled), or null
91
- // portalClient = unique session/tab ID (assigned by server on WS connect)
92
- return <div className="p-4 text-lg">{JSON.stringify(data)}</div>;
93
- }
94
- ```
95
+ Portal-react has three routing modes — broadcast, user-cast (every tab of one user), and unicast (one specific tab). Everything works without authentication too — user-cast just degrades gracefully when there is no user.
96
+
97
+ ### Identity
95
98
 
96
- ## Portal Authentication
99
+ | Identifier | Scope | Persistence | Source |
100
+ |---|---|---|---|
101
+ | `portalClient` | Single WS session (one tab) | Lost on reconnect — server assigns a new UUID | Generated server-side on connect |
102
+ | `userId` / `username` | All sessions of a user | Survives reconnect and new tabs | `x-portal-user-*` headers (when **Portal Auth** is enabled) |
97
103
 
98
- When **Portal Auth** is checked, the node extracts user identity from incoming request headers:
104
+ **Portal Auth header contract** injected by an upstream reverse proxy such as `aaqu-portal-auth`:
99
105
 
100
106
  | Header | Field |
101
107
  |---|---|
@@ -106,66 +112,61 @@ When **Portal Auth** is checked, the node extracts user identity from incoming r
106
112
  | `x-portal-user-role` | `role` |
107
113
  | `x-portal-user-groups` | `groups` (JSON array) |
108
114
 
109
- - In the browser, `useNodeRed().user` returns the extracted user object (or `null` if auth is disabled or no headers present).
110
- - Every WebSocket message includes `msg._client = { portalClient, ...userFields }`. The `portalClient` is always present (unique per tab/session); user fields are added when Portal Auth is enabled.
111
- - To send a response to a specific tab, keep `msg._client` on the return message (or set `msg._client = { portalClient: "..." }`).
112
- - To send to all sessions of a user, set `msg._client = { userId: "..." }` (omit `portalClient`).
113
- - To broadcast to all clients, remove `msg._client` from the message.
115
+ If Portal Auth is disabled or headers are absent, `user` is `null` and user-scoped features are silently skipped broadcast and per-session features still work.
114
116
 
115
- ## Portal Assets
117
+ ### Routing modes
116
118
 
117
- Static files (3D models, textures, fonts, etc.) can be uploaded and served from a public endpoint.
119
+ Every inbound `msg` arrives in your flow with `msg._client` already filled in by the server. The flow then decides where the response should go by setting (or clearing) `msg._client` on the outgoing `msg`:
118
120
 
119
- - Open the **Portal Assets** tab in the Node-RED sidebar
120
- - Upload files via button or drag & drop
121
- - Organize in folders (create, rename, move between folders)
122
- - Copy public path with one click — use in JSX: `/fromcubes/public/models/scene.glb`
123
- - Download and delete files from the context menu
121
+ ```javascript
122
+ // BROADCAST everyone connected to this endpoint
123
+ delete msg._client;
124
+ return msg;
124
125
 
125
- All uploads require Node-RED admin authentication. Files are served publicly at `/fromcubes/public/`.
126
+ // UNICAST only the tab that sent the original msg (echo)
127
+ // Keep msg._client as-is; it already carries portalClient.
128
+ return msg;
126
129
 
127
- Limits: 100 MB per file, 500 MB total, 1000 files max.
130
+ // UNICAST a specific tab whose ID you know
131
+ msg._client = { portalClient: "a1b2c3d4-..." };
132
+ return msg;
128
133
 
129
- ## Deploy lifecycle
134
+ // USER-CAST — every tab of a specific user (even ones that just opened)
135
+ msg._client = { userId: "alice" };
136
+ return msg;
137
+ ```
130
138
 
131
- What happens on each deploy:
139
+ **Anti-spoof guarantee.** On every inbound message the server overwrites `msg._client` from scratch using the socket's own `portalClient` and the user data captured at connect. A browser cannot forge `_client` — whatever it puts there is discarded.
132
140
 
133
- 1. Node `close` fires on existing instance
134
- 2. All WebSocket clients receive close code `1001` ("node redeployed")
135
- 3. Browser auto-reconnects with exponential backoff (500ms → 1s → 2s → 4s → 8s cap)
136
- 4. Stale HTTP route and WS upgrade handler removed
137
- 5. New instance transpiles JSX:
138
- - Content hash computed
139
- - Cache hit → reuse (0ms)
140
- - Cache miss → esbuild transpile (~5ms)
141
- 6. New HTTP route and WS handler registered
142
- 7. Reconnecting clients soft-reconnect (no page reload) and receive current `lastPayload`
141
+ **User-cast uses an O(1) index** so a message to `{userId: "alice"}` reaches every tab of Alice with a single lookup, not a scan.
143
142
 
144
- Rapid deploys (user clicking deploy repeatedly) are safe:
145
- - `isClosing` flag prevents accepting new WS connections during teardown
146
- - Upgrade handlers are tracked per node ID, old ones removed before new ones register
147
- - No orphan listeners accumulate on `RED.server`
143
+ ### Without a user (anonymous mode)
148
144
 
149
- Transpile errors:
150
- - Node status shows red "transpile error"
151
- - Endpoint serves an error page with the error message
152
- - Fix code, redeploy — cache invalidates because hash changes
145
+ If **Portal Auth** is off (or no proxy headers arrive), everything still works:
153
146
 
154
- ## Browser payload
147
+ - `broadcast` and `portalClient` unicast: unchanged
148
+ - `user`-cast: gracefully skipped (no `userId` to target)
149
+ - `useNodeRed().user` is `null`
155
150
 
156
- | Asset | Size (gzip) |
157
- |---|---|
158
- | Single JS bundle (React + packages + your code) | ~45 KB React only, grows with packages |
159
- | Tailwind CSS (server-compiled) | stored per-page, reused if JSX unchanged |
160
- | WebSocket bridge | <1 KB |
151
+ Same model as dashboard 2 — no auth required to use the node.
161
152
 
162
- Single-pass esbuild bundle — React, npm packages, and your JSX compiled into one IIFE. Tree-shaking removes unused exports. React `alias` ensures packages with React peer deps (e.g. `@react-three/fiber`, `@pixi/react`) share the same React instance — no duplicate React, no hooks errors.
153
+ ## Portal Assets
163
154
 
164
- No Babel, no Sucrase, no runtime compiler in the browser.
155
+ Static files (3D models, textures, fonts, etc.) can be uploaded and served from a public endpoint.
156
+
157
+ - Open the **Portal Assets** tab in the Node-RED sidebar
158
+ - Upload files via button or drag & drop
159
+ - Organize in folders (create, rename, move between folders)
160
+ - Copy public path with one click — use in JSX: `/fromcubes/public/models/scene.glb`
161
+ - Download and delete files from the context menu
162
+
163
+ All uploads require Node-RED admin authentication. Files are served publicly at `/fromcubes/public/`.
164
+
165
+ Limits: 100 MB per file, 500 MB total, 1000 files max.
165
166
 
166
167
  ## Examples
167
168
 
168
- Import `001-shared-components-flow.json` first — it provides shared UI components (Page, Header, Stat, Button, ValueBadge) used by all examples.
169
+ Import `001-shared-components-flow.json` first — it provides shared UI components (Page, Header, Stat, Button, ValueBadge) used by the others.
169
170
 
170
171
  | Flow | npm packages | Description |
171
172
  |---|---|---|
@@ -177,6 +178,18 @@ Import `001-shared-components-flow.json` first — it provides shared UI compone
177
178
  | `006-pixi-portal-flow.json` | `pixi.js`, `@pixi/react` | Clickable bunny sprites with PixiJS |
178
179
  | `007-webgpu-tsl-flow.json` | `three` | WebGPU renderer + TSL animated shaders |
179
180
 
181
+ ## Troubleshooting
182
+
183
+ | Symptom | Likely cause |
184
+ |---|---|
185
+ | Red status "transpile error" + error page on the endpoint | JSX syntax error — fix code, redeploy (cache invalidates automatically) |
186
+ | Red status "legacy endpoint" on deploy | Flow was saved before the `/fromcubes/` prefix became hardcoded. Open the node, set **Sub-path**, redeploy. Automatic migration is disabled to avoid silent URL changes. |
187
+ | Red status "bad sub-path" | Sub-path is empty or violates the rules (no leading `/`, no whitespace, no `..`, segments must start alphanumerically, `public`/`_ws` reserved). |
188
+ | Page loads but `data` stays `undefined` | No input wire has fired yet — broadcast something into the node |
189
+ | `user` is `null` even with Portal Auth on | Upstream proxy is not injecting `x-portal-user-*` headers |
190
+ | New tab shows the previous broadcast value | Expected — that's the recovery frame. Use `useNodeRed({ ignoreRecovery: true })` to opt out |
191
+ | Page reloads on every deploy | Expected for code changes; clients soft-reconnect with exponential backoff |
192
+
180
193
  ## License
181
194
 
182
195
  Apache-2.0
@@ -47,7 +47,7 @@
47
47
  "type": "portal-react",
48
48
  "z": "fc-demo-flow",
49
49
  "name": "Sensor Portal",
50
- "endpoint": "/fromcubes/sensors",
50
+ "subPath": "sensors",
51
51
  "pageTitle": "Sensors",
52
52
  "customHead": "",
53
53
  "inputSchema": "[{\"name\":\"temp\",\"type\":\"number\"},{\"name\":\"humidity\",\"type\":\"number\"},{\"name\":\"pressure\",\"type\":\"number\"},{\"name\":\"ts\",\"type\":\"number\"}]",
@@ -59,7 +59,7 @@
59
59
  "type": "portal-react",
60
60
  "z": "fc-chart-flow",
61
61
  "name": "Chart Portal",
62
- "endpoint": "/fromcubes/chart",
62
+ "subPath": "chart",
63
63
  "pageTitle": "fromcubes charts",
64
64
  "customHead": "",
65
65
  "portalAuth": false,
@@ -46,7 +46,7 @@
46
46
  "type": "portal-react",
47
47
  "z": "fc-d3-flow",
48
48
  "name": "Poland Map",
49
- "endpoint": "/fromcubes/poland",
49
+ "subPath": "poland",
50
50
  "pageTitle": "Poland Energy Grid",
51
51
  "customHead": "",
52
52
  "portalAuth": false,
@@ -58,7 +58,7 @@
58
58
  "type": "portal-react",
59
59
  "z": "fc-three-flow",
60
60
  "name": "3D Portal",
61
- "endpoint": "/fromcubes/3d",
61
+ "subPath": "threejs",
62
62
  "pageTitle": "fromcubes 3D",
63
63
  "customHead": "",
64
64
  "portalAuth": false,
@@ -58,7 +58,7 @@
58
58
  "type": "portal-react",
59
59
  "z": "fc-pixi-flow",
60
60
  "name": "Pixi Portal",
61
- "endpoint": "/fromcubes/pixi",
61
+ "subPath": "pixi",
62
62
  "pageTitle": "fromcubes Pixi",
63
63
  "customHead": "",
64
64
  "portalAuth": false,
@@ -58,7 +58,7 @@
58
58
  "type": "portal-react",
59
59
  "z": "fc-webgpu-flow",
60
60
  "name": "WebGPU Portal",
61
- "endpoint": "/fromcubes/webgpu",
61
+ "subPath": "webgpu",
62
62
  "pageTitle": "fromcubes WebGPU",
63
63
  "customHead": "",
64
64
  "portalAuth": false,
@@ -50,6 +50,50 @@ function isSafeName(name) {
50
50
  );
51
51
  }
52
52
 
53
+ const SUB_PATH_SEGMENT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
54
+ const SUB_PATH_RESERVED = new Set(["public", "_ws"]);
55
+
56
+ function validateSubPath(input) {
57
+ if (typeof input !== "string") {
58
+ return { ok: false, error: "Sub-path is required" };
59
+ }
60
+ const trimmed = input.trim();
61
+ if (trimmed.length === 0) {
62
+ return { ok: false, error: "Sub-path is required" };
63
+ }
64
+ if (/\s/.test(trimmed)) {
65
+ return { ok: false, error: "Sub-path must not contain whitespace" };
66
+ }
67
+ if (trimmed.startsWith("/")) {
68
+ return { ok: false, error: "Sub-path must not start with /" };
69
+ }
70
+ if (trimmed.endsWith("/")) {
71
+ return { ok: false, error: "Sub-path must not end with /" };
72
+ }
73
+ const segments = trimmed.split("/");
74
+ for (const seg of segments) {
75
+ if (seg.length === 0) {
76
+ return { ok: false, error: "Sub-path must not contain empty segments" };
77
+ }
78
+ if (seg === "." || seg === "..") {
79
+ return { ok: false, error: "Path traversal not allowed in sub-path" };
80
+ }
81
+ if (SUB_PATH_RESERVED.has(seg.toLowerCase())) {
82
+ return {
83
+ ok: false,
84
+ error: `Sub-path segment "${seg}" is reserved`,
85
+ };
86
+ }
87
+ if (!SUB_PATH_SEGMENT_RE.test(seg)) {
88
+ return {
89
+ ok: false,
90
+ error: `Sub-path segment "${seg}" contains invalid characters`,
91
+ };
92
+ }
93
+ }
94
+ return { ok: true, value: trimmed };
95
+ }
96
+
53
97
  function extractPortalUser(headers) {
54
98
  const user = {};
55
99
  if (headers["x-portal-user-id"]) user.userId = headers["x-portal-user-id"];
@@ -90,6 +134,13 @@ function escScript(s) {
90
134
  }
91
135
 
92
136
  module.exports = function (RED) {
137
+ return createHelpers(RED);
138
+ };
139
+
140
+ module.exports.validateSubPath = validateSubPath;
141
+ module.exports.isSafeName = isSafeName;
142
+
143
+ function createHelpers(RED) {
93
144
  // Package root — where react/react-dom live (this package's own node_modules)
94
145
  const pkgRoot = path.join(__dirname, "../..");
95
146
  // userDir — where dynamicModuleList installs user packages
@@ -255,6 +306,7 @@ module.exports = function (RED) {
255
306
  extractPortalUser,
256
307
  removeRoute,
257
308
  isSafeName,
309
+ validateSubPath,
258
310
  esc,
259
311
  escScript,
260
312
  pkgRoot,
@@ -267,4 +319,4 @@ module.exports = function (RED) {
267
319
  deleteCacheFiles,
268
320
  isHashInUse,
269
321
  };
270
- };
322
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Plugin hook system for @aaqu/fromcubes-portal-react.
3
+ *
4
+ * Plugins register with Node-RED via:
5
+ * RED.plugins.registerPlugin("my-plugin", {
6
+ * type: "fromcubes-portal-react",
7
+ * hooks: {
8
+ * onIsValidConnection(request) { return true },
9
+ * onCanSendTo(ws, msg) { return true },
10
+ * onInbound(msg, ws) { return msg },
11
+ * },
12
+ * })
13
+ *
14
+ * Semantics:
15
+ * - allow(name, ...args): every registered hook must return !== false
16
+ * (no hooks registered -> allowed). AND logic across plugins.
17
+ * - transform(name, msg, ...args): runs each hook sequentially, each
18
+ * may return a new msg. Returning undefined keeps the current msg.
19
+ * - Any thrown exception is treated as `false` for allow hooks and
20
+ * logged via RED.log.error. Transform hooks log and skip the step.
21
+ */
22
+
23
+ const PLUGIN_TYPE = "fromcubes-portal-react";
24
+
25
+ module.exports = function (RED) {
26
+ function getHooks(name) {
27
+ let plugins = [];
28
+ try {
29
+ plugins = RED.plugins.getByType(PLUGIN_TYPE) || [];
30
+ } catch (_) {
31
+ return [];
32
+ }
33
+ const out = [];
34
+ for (const p of plugins) {
35
+ const fn = p && p.hooks && p.hooks[name];
36
+ if (typeof fn === "function") out.push({ fn, id: p.id || p.name || "?" });
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function allow(name, ...args) {
42
+ const hooks = getHooks(name);
43
+ if (hooks.length === 0) return true;
44
+ for (const h of hooks) {
45
+ let result;
46
+ try {
47
+ result = h.fn(...args);
48
+ } catch (e) {
49
+ RED.log.error(
50
+ `[portal-react] hook ${name} (${h.id}) threw: ${e.message}`,
51
+ );
52
+ return false;
53
+ }
54
+ if (result === false) return false;
55
+ }
56
+ return true;
57
+ }
58
+
59
+ function transform(name, msg, ...args) {
60
+ const hooks = getHooks(name);
61
+ let current = msg;
62
+ for (const h of hooks) {
63
+ try {
64
+ const next = h.fn(current, ...args);
65
+ if (next !== undefined) current = next;
66
+ } catch (e) {
67
+ RED.log.error(
68
+ `[portal-react] hook ${name} (${h.id}) threw: ${e.message}`,
69
+ );
70
+ }
71
+ }
72
+ return current;
73
+ }
74
+
75
+ function hasHook(name) {
76
+ return getHooks(name).length > 0;
77
+ }
78
+
79
+ return { allow, transform, hasHook, PLUGIN_TYPE };
80
+ };
81
+
82
+ module.exports.PLUGIN_TYPE = PLUGIN_TYPE;
@@ -43,6 +43,7 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
43
43
  _ws: null,
44
44
  _listeners: new Set(),
45
45
  _lastData: null,
46
+ _ignoreRecovery: false,
46
47
  _retries: 0,
47
48
  _wasConnected: false,
48
49
  _version: null,
@@ -108,6 +109,14 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
108
109
  this._lastData = m.payload;
109
110
  this._listeners.forEach(fn => fn(m.payload));
110
111
  }
112
+ if (m.type === 'recovery') {
113
+ // Cached last broadcast at connect time. Seeded into
114
+ // _lastData unless the page opted out via
115
+ // useNodeRed({ ignoreRecovery: true }).
116
+ if (this._ignoreRecovery) return;
117
+ this._lastData = m.payload;
118
+ this._listeners.forEach(fn => fn(m.payload));
119
+ }
111
120
  } catch (err) { console.error('WS parse', err); }
112
121
  };
113
122
 
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pure routing function for portal-react WS outbound messages.
3
+ *
4
+ * Split out from portal-react.js so it can be unit-tested without a full
5
+ * Node-RED runtime.
6
+ *
7
+ * Routing modes, in priority order:
8
+ * 1. msg._client.portalClient → unicast to that one session
9
+ * 2. msg._client.userId → user-cast (O(1) via userIndex)
10
+ * 3. msg._client.username → user-cast fallback (O(N) scan)
11
+ * 4. otherwise → broadcast
12
+ *
13
+ * Returns a shallow summary { mode, delivered } for observability/tests.
14
+ * The caller is responsible for any side-effects keyed off the mode
15
+ * (e.g. caching the last broadcast payload for new-client recovery).
16
+ */
17
+
18
+ function route(msg, ctx) {
19
+ const { clients, userIndex, sendTo } = ctx;
20
+ const target = msg && msg._client;
21
+ const frame = JSON.stringify({ type: "data", payload: msg.payload });
22
+
23
+ let delivered = 0;
24
+
25
+ if (target && target.portalClient) {
26
+ if (sendTo(clients.get(target.portalClient), frame, msg)) delivered++;
27
+ return { mode: "unicast", delivered };
28
+ }
29
+
30
+ if (target && target.userId) {
31
+ const set = userIndex.get(target.userId);
32
+ if (set) {
33
+ set.forEach((ws) => {
34
+ if (sendTo(ws, frame, msg)) delivered++;
35
+ });
36
+ }
37
+ return { mode: "user-cast", delivered };
38
+ }
39
+
40
+ if (target && target.username) {
41
+ clients.forEach((ws) => {
42
+ if (ws._portalUser && ws._portalUser.username === target.username) {
43
+ if (sendTo(ws, frame, msg)) delivered++;
44
+ }
45
+ });
46
+ return { mode: "user-cast", delivered };
47
+ }
48
+
49
+ // Broadcast.
50
+ clients.forEach((ws) => {
51
+ if (sendTo(ws, frame, msg)) delivered++;
52
+ });
53
+ return { mode: "broadcast", delivered };
54
+ }
55
+
56
+ module.exports = { route };
@@ -56,7 +56,7 @@
56
56
  var libContent = [
57
57
  "declare var React: any;",
58
58
  "declare var ReactDOM: any;",
59
- "declare function useNodeRed(): { data: any; send: (payload: any, topic?: string) => void; user: { userId?: string; userName?: string; username?: string; email?: string; role?: string; groups?: any[] } | null; portalClient: string | null };",
59
+ "declare function useNodeRed(opts?: { ignoreRecovery?: boolean }): { data: any; send: (payload: any, topic?: string) => void; user: { userId?: string; userName?: string; username?: string; email?: string; role?: string; groups?: any[] } | null; portalClient: string | null };",
60
60
  ].join("\n");
61
61
  console.log(PREFIX, "addExtraLib globals.d.ts");
62
62
  jsDef.addExtraLib(libContent, "file:///globals.d.ts");
@@ -813,10 +813,22 @@
813
813
  <input type="text" id="node-input-name" placeholder="My Portal" />
814
814
  </div>
815
815
  <div class="form-row">
816
- <label for="node-input-endpoint"
817
- ><i class="fa fa-globe"></i> Endpoint</label
816
+ <label for="node-input-subPath"
817
+ ><i class="fa fa-globe"></i> Sub-path</label
818
818
  >
819
- <input type="text" id="node-input-endpoint" placeholder="/fromcubes" />
819
+ <div style="display:inline-flex;align-items:stretch;width:70%;">
820
+ <span
821
+ style="display:inline-flex;align-items:center;padding:0 8px;background:var(--red-ui-secondary-background,#f3f3f3);border:1px solid var(--red-ui-form-input-border-color,#ccc);border-right:none;border-radius:4px 0 0 4px;color:var(--red-ui-secondary-text-color,#888);font-family:monospace;user-select:none;"
822
+ >/fromcubes/</span
823
+ >
824
+ <input
825
+ type="text"
826
+ id="node-input-subPath"
827
+ placeholder="sensors"
828
+ style="flex:1;border-radius:0 4px 4px 0;"
829
+ />
830
+ </div>
831
+ <input type="hidden" id="node-input-endpoint" />
820
832
  <div
821
833
  style="font-size:11px;opacity:.5;margin-top:2px;margin-left:105px;"
822
834
  id="fc-url-hint"
@@ -929,7 +941,27 @@
929
941
  color: "#61dafb",
930
942
  defaults: {
931
943
  name: { value: "" },
932
- endpoint: { value: "/fromcubes", required: true },
944
+ subPath: {
945
+ value: "",
946
+ required: true,
947
+ validate: function (v) {
948
+ if (typeof v !== "string") return false;
949
+ var t = v.trim();
950
+ if (t.length === 0) return false;
951
+ if (/\s/.test(t)) return false;
952
+ if (t.charAt(0) === "/" || t.charAt(t.length - 1) === "/") return false;
953
+ var segs = t.split("/");
954
+ for (var i = 0; i < segs.length; i++) {
955
+ var s = segs[i];
956
+ if (!s || s === "." || s === "..") return false;
957
+ var lower = s.toLowerCase();
958
+ if (lower === "public" || lower === "_ws") return false;
959
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(s)) return false;
960
+ }
961
+ return true;
962
+ },
963
+ },
964
+ endpoint: { value: "" },
933
965
  pageTitle: { value: "fromcubes" },
934
966
  componentCode: { value: STARTER },
935
967
  customHead: { value: "" },
@@ -942,7 +974,7 @@
942
974
  icon: "font-awesome/fa-desktop",
943
975
  paletteLabel: "fromcubes portal",
944
976
  label: function () {
945
- return this.name || this.endpoint || "portal react";
977
+ return this.name || ("/fromcubes/" + (this.subPath || "?"));
946
978
  },
947
979
 
948
980
  oneditprepare: function () {
@@ -984,13 +1016,45 @@
984
1016
  fcTabs.addTab({ id: "fc-tab-head", label: "Head HTML" });
985
1017
  fcTabs.activateTab("fc-tab-jsx");
986
1018
 
1019
+ // Legacy endpoint detection + convenience pre-fill
1020
+ if (node.endpoint && typeof node.endpoint === "string") {
1021
+ var legacy = node.endpoint;
1022
+ var prefix = "/fromcubes/";
1023
+ if (legacy.indexOf(prefix) === 0) {
1024
+ if (!node.subPath) {
1025
+ $("#node-input-subPath").val(legacy.slice(prefix.length));
1026
+ }
1027
+ } else if (legacy !== "" && legacy !== "/fromcubes") {
1028
+ $("#fc-url-hint")
1029
+ .css("color", "#c00")
1030
+ .text(
1031
+ "Legacy endpoint detected: '" +
1032
+ legacy +
1033
+ "'. Set a Sub-path and redeploy.",
1034
+ );
1035
+ }
1036
+ }
1037
+
987
1038
  // URL hint
988
1039
  function updateHint() {
989
- var ep = $("#node-input-endpoint").val() || "/fromcubes";
1040
+ var sp = ($("#node-input-subPath").val() || "").trim();
990
1041
  var root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
991
- $("#fc-url-hint").text("Page served at: http://<host>:1880" + root + ep);
1042
+ if (sp) {
1043
+ $("#fc-url-hint")
1044
+ .css("color", "")
1045
+ .text(
1046
+ "Page served at: http://<host>:1880" +
1047
+ root +
1048
+ "/fromcubes/" +
1049
+ sp,
1050
+ );
1051
+ } else if (!node.endpoint || node.endpoint.indexOf("/fromcubes/") === 0) {
1052
+ $("#fc-url-hint")
1053
+ .css("color", "")
1054
+ .text("Sub-path required (will be served under /fromcubes/<sub-path>)");
1055
+ }
992
1056
  }
993
- $("#node-input-endpoint").on("input", updateHint);
1057
+ $("#node-input-subPath").on("input", updateHint);
994
1058
  updateHint();
995
1059
 
996
1060
  // Modules editableList (like function node's libs)
@@ -1117,9 +1181,9 @@
1117
1181
  });
1118
1182
 
1119
1183
  $("#fc-btn-preview").on("click", function () {
1120
- var ep = $("#node-input-endpoint").val();
1184
+ var sp = ($("#node-input-subPath").val() || "").trim();
1121
1185
  var root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1122
- if (ep) window.open(root + ep, "_blank");
1186
+ if (sp) window.open(root + "/fromcubes/" + sp, "_blank");
1123
1187
  });
1124
1188
 
1125
1189
  $("#fc-btn-components").on("click", function () {
@@ -1239,6 +1303,11 @@
1239
1303
  oneditsave: function () {
1240
1304
  console.log("[FC-Monaco] PORTAL oneditsave");
1241
1305
 
1306
+ // Clear legacy endpoint field + normalize subPath
1307
+ $("#node-input-endpoint").val("");
1308
+ this.endpoint = "";
1309
+ this.subPath = ($("#node-input-subPath").val() || "").trim();
1310
+
1242
1311
  // Collect libs from editableList
1243
1312
  var libs = [];
1244
1313
  var items = $("#node-input-libs-container").editableList("items");
@@ -85,6 +85,7 @@ module.exports = function (RED) {
85
85
  extractPortalUser,
86
86
  removeRoute,
87
87
  isSafeName,
88
+ validateSubPath,
88
89
  userDir,
89
90
  readCachedJS,
90
91
  writeCachedJS,
@@ -94,6 +95,14 @@ module.exports = function (RED) {
94
95
  isHashInUse,
95
96
  } = helpers;
96
97
  const { buildPage, buildErrorPage } = require("./lib/page-builder");
98
+ const hooks = require("./lib/hooks")(RED);
99
+ const router = require("./lib/router");
100
+
101
+ // Per-process cache of the last broadcast payload per endpoint.
102
+ // Lets a freshly-connected client see the most recent broadcast value
103
+ // (similar to dashboard2's lastMsg recovery). Sent as a distinct
104
+ // `recovery` WS frame so React can opt out via useNodeRed({ ignoreRecovery: true }).
105
+ const lastBroadcastCache = new Map();
97
106
 
98
107
  // ── Canvas node: shared component ─────────────────────────────
99
108
 
@@ -153,7 +162,33 @@ module.exports = function (RED) {
153
162
  const nodeId = node.id;
154
163
 
155
164
  // Config
156
- const endpoint = (config.endpoint || "/fromcubes").replace(/\/+$/, "");
165
+ const subPathResult = validateSubPath(config.subPath);
166
+ const legacyEndpoint =
167
+ typeof config.endpoint === "string" && config.endpoint.trim().length > 0
168
+ ? config.endpoint.trim()
169
+ : null;
170
+
171
+ if (!subPathResult.ok) {
172
+ // No valid subPath. If there's a legacy endpoint, hard-fail with a
173
+ // migration message; otherwise fail on the sub-path error.
174
+ if (legacyEndpoint) {
175
+ node.error(
176
+ `Legacy 'endpoint' field detected ("${legacyEndpoint}"). ` +
177
+ "Open the node, set a Sub-path (served under /fromcubes/<sub-path>), and redeploy.",
178
+ );
179
+ node.status({ fill: "red", shape: "ring", text: "legacy endpoint" });
180
+ } else {
181
+ node.error("Invalid sub-path: " + subPathResult.error);
182
+ node.status({ fill: "red", shape: "ring", text: "bad sub-path" });
183
+ }
184
+ node.on("close", function (_removed, done) {
185
+ if (done) done();
186
+ });
187
+ return;
188
+ }
189
+ const subPath = subPathResult.value;
190
+ const endpoint = "/fromcubes/" + subPath;
191
+
157
192
  const componentCode = config.componentCode || "";
158
193
  const pageTitle = config.pageTitle || "Portal";
159
194
  const customHead = config.customHead || "";
@@ -180,8 +215,8 @@ module.exports = function (RED) {
180
215
  endpointOwners[endpoint] = nodeId;
181
216
 
182
217
  // State
183
- const clients = new Map(); // portalId → ws
184
- let lastPayload = null;
218
+ const clients = new Map(); // portalClient → ws
219
+ const userIndex = new Map(); // userId → Set<ws> (O(1) user-cast)
185
220
  let wsServer = null;
186
221
  let isClosing = false;
187
222
  let lastJsxHash = null;
@@ -293,11 +328,14 @@ module.exports = function (RED) {
293
328
  "",
294
329
  "// ── useNodeRed hook ──",
295
330
  [
296
- "function useNodeRed() {",
331
+ "function useNodeRed(opts) {",
332
+ " // opts.ignoreRecovery = true → ignore the cached last-broadcast",
333
+ " // frame the server sends on connect; data stays undefined until",
334
+ " // a fresh broadcast arrives. Latched once globally — strictest",
335
+ " // call wins (any caller asking to ignore disables recovery for all).",
336
+ " if (opts && opts.ignoreRecovery) window.__NR._ignoreRecovery = true;",
297
337
  " const [data, setData] = React.useState(window.__NR._lastData);",
298
- " React.useEffect(() => {",
299
- " return window.__NR.subscribe(setData);",
300
- " }, []);",
338
+ " React.useEffect(() => window.__NR.subscribe(setData), []);",
301
339
  " const send = React.useCallback((payload, topic) => {",
302
340
  " window.__NR.send(payload, topic);",
303
341
  " }, []);",
@@ -566,6 +604,12 @@ module.exports = function (RED) {
566
604
  pathname = request.url;
567
605
  }
568
606
  if (pathname === wsPath) {
607
+ // Plugin hook: plugins may reject the connection before upgrade.
608
+ // Default (no plugins) = allowed, matches dashboard behavior.
609
+ if (!hooks.allow("onIsValidConnection", request)) {
610
+ try { socket.destroy(); } catch (_) {}
611
+ return;
612
+ }
569
613
  wsServer.handleUpgrade(request, socket, head, (ws) => {
570
614
  wsServer.emit("connection", ws, request);
571
615
  });
@@ -586,13 +630,20 @@ module.exports = function (RED) {
586
630
  ws._portalUser = extractPortalUser(request.headers);
587
631
  }
588
632
  clients.set(portalClient, ws);
589
- updateStatus();
590
633
 
591
- // Push current state to new client
592
- if (lastPayload !== null) {
593
- wsSend(ws, { type: "data", payload: lastPayload });
634
+ // Index by userId for O(1) user-cast routing
635
+ const userId = ws._portalUser && ws._portalUser.userId;
636
+ if (userId) {
637
+ let set = userIndex.get(userId);
638
+ if (!set) {
639
+ set = new Set();
640
+ userIndex.set(userId, set);
641
+ }
642
+ set.add(ws);
594
643
  }
595
644
 
645
+ updateStatus();
646
+
596
647
  // Send content version for deploy-reload detection
597
648
  const contentHash = pageState[endpoint]?.contentHash || "";
598
649
  wsSend(ws, { type: "version", hash: contentHash });
@@ -600,35 +651,55 @@ module.exports = function (RED) {
600
651
  // Send assigned portalClient to browser
601
652
  wsSend(ws, { type: "hello", portalClient });
602
653
 
654
+ // Send the cached last broadcast (if any) as a distinct
655
+ // `recovery` frame. The browser uses this to seed `data` on a
656
+ // fresh connection. React components can opt out via
657
+ // useNodeRed({ ignoreRecovery: true }).
658
+ if (lastBroadcastCache.has(endpoint)) {
659
+ wsSend(ws, { type: "recovery", payload: lastBroadcastCache.get(endpoint) });
660
+ }
661
+
603
662
  ws.on("message", (raw) => {
604
663
  try {
605
664
  const msg = JSON.parse(raw.toString());
606
665
  if (msg.type === "output") {
607
- const out = {
666
+ let out = {
608
667
  payload: msg.payload,
609
668
  topic: msg.topic || "",
610
669
  };
670
+ // Server-side identity injection — the client cannot forge
671
+ // _client because we build it from ws state, not from the
672
+ // inbound frame.
611
673
  const client = { portalClient: ws._portalClient };
612
674
  if (portalAuth && ws._portalUser) {
613
675
  Object.assign(client, ws._portalUser);
614
676
  }
615
677
  out._client = client;
616
- node.send(out);
678
+ // Transform hook — plugins may mutate / drop the msg.
679
+ // A hook returning null signals "drop this message".
680
+ out = hooks.transform("onInbound", out, ws);
681
+ if (out) node.send(out);
682
+ return;
617
683
  }
618
684
  } catch (e) {
619
685
  node.warn("Bad WS message: " + e.message);
620
686
  }
621
687
  });
622
688
 
623
- ws.on("close", () => {
689
+ const detach = () => {
624
690
  clients.delete(portalClient);
691
+ if (userId) {
692
+ const set = userIndex.get(userId);
693
+ if (set) {
694
+ set.delete(ws);
695
+ if (set.size === 0) userIndex.delete(userId);
696
+ }
697
+ }
625
698
  updateStatus();
626
- });
699
+ };
627
700
 
628
- ws.on("error", () => {
629
- clients.delete(portalClient);
630
- updateStatus();
631
- });
701
+ ws.on("close", detach);
702
+ ws.on("error", detach);
632
703
  });
633
704
  } catch (e) {
634
705
  node.error("WebSocket setup failed: " + e.message);
@@ -636,37 +707,27 @@ module.exports = function (RED) {
636
707
 
637
708
  // ── Input handler ─────────────────────────────────────────
638
709
 
639
- node.on("input", (msg, send, done) => {
640
- const target = msg._client;
641
- const frame = JSON.stringify({ type: "data", payload: msg.payload });
642
-
643
- if (target && target.portalClient) {
644
- // Target specific client by portalClient
645
- const ws = clients.get(target.portalClient);
646
- if (ws && ws.readyState === 1) ws.send(frame);
647
- } else if (target && (target.userId || target.username)) {
648
- // Target all sessions of a specific user
649
- const matchId = target.userId;
650
- const matchName = target.username;
651
- clients.forEach((ws) => {
652
- if (ws.readyState !== 1) return;
653
- const u = ws._portalUser;
654
- if (!u) return;
655
- if (
656
- (matchId && u.userId === matchId) ||
657
- (matchName && u.username === matchName)
658
- ) {
659
- ws.send(frame);
660
- }
661
- });
662
- } else {
663
- // Broadcast to all (default)
664
- lastPayload = msg.payload;
665
- clients.forEach((ws) => {
666
- if (ws.readyState === 1) ws.send(frame);
667
- });
710
+ // sendTo: single point where every outbound frame passes through
711
+ // the onCanSendTo hook. Strict-by-default — no opt-in per widget
712
+ // type like dashboard's acceptsClientConfig.
713
+ function sendTo(ws, frame, msg) {
714
+ if (!ws || ws.readyState !== 1) return false;
715
+ if (!hooks.allow("onCanSendTo", ws, msg)) return false;
716
+ try {
717
+ ws.send(frame);
718
+ return true;
719
+ } catch (_) {
720
+ return false;
668
721
  }
722
+ }
669
723
 
724
+ node.on("input", (msg, send, done) => {
725
+ const result = router.route(msg, { clients, userIndex, sendTo });
726
+ // Cache the latest broadcast payload so freshly-connected clients
727
+ // can recover it via the `recovery` frame on connect.
728
+ if (result.mode === "broadcast") {
729
+ lastBroadcastCache.set(endpoint, msg.payload);
730
+ }
670
731
  updateStatus();
671
732
  if (done) done();
672
733
  });
@@ -706,6 +767,16 @@ module.exports = function (RED) {
706
767
  delete endpointOwners[endpoint];
707
768
  }
708
769
 
770
+ // Drop the recovery cache only on full node removal — on a
771
+ // redeploy we keep it so reconnecting clients still recover.
772
+ if (removed) {
773
+ lastBroadcastCache.delete(endpoint);
774
+ }
775
+
776
+ // Clear the userIndex — WS clients are already closed above, but
777
+ // the Map itself should not outlive the node instance.
778
+ userIndex.clear();
779
+
709
780
  // Clean up route only when node is fully removed (not redeployed)
710
781
  if (removed) {
711
782
  // Delete disk cache if no other endpoint uses this hash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaqu/fromcubes-portal-react",
3
- "version": "0.1.0-alpha.15",
3
+ "version": "0.1.0-alpha.17",
4
4
  "description": "Fromcubes Portal - React for Node-RED with Tailwind CSS and auto complete",
5
5
  "keywords": [
6
6
  "node-red",
@@ -26,17 +26,17 @@
26
26
  "node": ">=18"
27
27
  },
28
28
  "dependencies": {
29
- "esbuild": "^0.27.4",
29
+ "esbuild": "^0.28.0",
30
30
  "monaco-editor": "^0.55.1",
31
- "react": "^19.2.4",
32
- "react-dom": "^19.2.4",
33
- "tailwindcss": "^4.2.1"
31
+ "react": "^19.2.5",
32
+ "react-dom": "^19.2.5",
33
+ "tailwindcss": "^4.2.2"
34
34
  },
35
35
  "devDependencies": {
36
- "node-red": "next",
37
- "prettier": "^3.8.1",
36
+ "node-red": "5.0.0-beta.4",
37
+ "prettier": "^3.8.2",
38
38
  "supertest": "^7.2.2",
39
- "vitest": "^4.1.2"
39
+ "vitest": "^4.1.4"
40
40
  },
41
41
  "scripts": {
42
42
  "start": "node-red",