@aaqu/fromcubes-portal-react 0.1.0-alpha.16 → 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 +124 -111
- package/examples/002-sensor-portal-flow.json +1 -1
- package/examples/003-chart-portal-flow.json +1 -1
- package/examples/004-d3-poland-flow.json +1 -1
- package/examples/005-threejs-portal-flow.json +1 -1
- package/examples/006-pixi-portal-flow.json +1 -1
- package/examples/007-webgpu-tsl-flow.json +1 -1
- package/nodes/lib/helpers.js +53 -1
- package/nodes/lib/hooks.js +82 -0
- package/nodes/lib/page-builder.js +9 -0
- package/nodes/lib/router.js +56 -0
- package/nodes/portal-react.html +80 -11
- package/nodes/portal-react.js +119 -48
- package/package.json +8 -8
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
|
|
3
|
+
> **⚠️ Alpha Module** — This project is in early development. Expect breaking changes. Test on a clean Node-RED instance.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
[
|
|
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
|
+
[](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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|---|---|
|
|
54
|
-
| `npm start` | Start Node-RED |
|
|
58
|
+
### Recovery on connect
|
|
55
59
|
|
|
56
|
-
|
|
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
|
-
|
|
62
|
+
Opt out per page:
|
|
59
63
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
77
|
-
- **Tailwind CSS autocompletion** inside `className="..."`
|
|
78
|
-
- **JSX tag completion** — type tag name, Tab to expand
|
|
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
|
|
91
|
+
- **Portal Assets sidebar** — file manager for static assets (GLB, textures, fonts…)
|
|
82
92
|
|
|
83
|
-
##
|
|
93
|
+
## Multi-user / Multi-tenancy
|
|
84
94
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
+
### Routing modes
|
|
116
118
|
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
// UNICAST — a specific tab whose ID you know
|
|
131
|
+
msg._client = { portalClient: "a1b2c3d4-..." };
|
|
132
|
+
return msg;
|
|
128
133
|
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
+
- `broadcast` and `portalClient` unicast: unchanged
|
|
148
|
+
- `user`-cast: gracefully skipped (no `userId` to target)
|
|
149
|
+
- `useNodeRed().user` is `null`
|
|
155
150
|
|
|
156
|
-
|
|
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
|
-
|
|
153
|
+
## Portal Assets
|
|
163
154
|
|
|
164
|
-
|
|
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
|
|
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
|
-
"
|
|
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\"}]",
|
package/nodes/lib/helpers.js
CHANGED
|
@@ -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 };
|
package/nodes/portal-react.html
CHANGED
|
@@ -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-
|
|
817
|
-
><i class="fa fa-globe"></i>
|
|
816
|
+
<label for="node-input-subPath"
|
|
817
|
+
><i class="fa fa-globe"></i> Sub-path</label
|
|
818
818
|
>
|
|
819
|
-
<
|
|
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
|
-
|
|
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.
|
|
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
|
|
1040
|
+
var sp = ($("#node-input-subPath").val() || "").trim();
|
|
990
1041
|
var root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
|
|
991
|
-
|
|
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-
|
|
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
|
|
1184
|
+
var sp = ($("#node-input-subPath").val() || "").trim();
|
|
1121
1185
|
var root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
|
|
1122
|
-
if (
|
|
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");
|
package/nodes/portal-react.js
CHANGED
|
@@ -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
|
|
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(); //
|
|
184
|
-
|
|
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
|
-
//
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
629
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
if (
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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.
|
|
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.
|
|
29
|
+
"esbuild": "^0.28.0",
|
|
30
30
|
"monaco-editor": "^0.55.1",
|
|
31
|
-
"react": "^19.2.
|
|
32
|
-
"react-dom": "^19.2.
|
|
33
|
-
"tailwindcss": "^4.2.
|
|
31
|
+
"react": "^19.2.5",
|
|
32
|
+
"react-dom": "^19.2.5",
|
|
33
|
+
"tailwindcss": "^4.2.2"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"node-red": "
|
|
37
|
-
"prettier": "^3.8.
|
|
36
|
+
"node-red": "5.0.0-beta.4",
|
|
37
|
+
"prettier": "^3.8.2",
|
|
38
38
|
"supertest": "^7.2.2",
|
|
39
|
-
"vitest": "^4.1.
|
|
39
|
+
"vitest": "^4.1.4"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"start": "node-red",
|