@aaqu/fromcubes-portal-react 0.1.0-alpha.35

Fromcubes Portal - React for Node-RED with Tailwind CSS and auto complete

npm install @aaqu/fromcubes-portal-react

@aaqu/fromcubes-portal-react

npm version npm downloads node Node-RED license Discord

πŸ“– Documentation β€” step-by-step guide with screenshots: aaqu.github.io/fromcubes-portal-react β€” install β†’ import β†’ edit JSX β†’ live dashboard, on one example.

Documentation walkthrough

⚠️ Alpha Module β€” This project is in early development. Expect breaking changes. Test on a clean Node-RED instance.

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.

For internals, plugin authoring, and the deploy pipeline see README-DEV.md.

πŸ’¬ Questions, feedback, showcase? Join the fromcubes Discord.

ko-fi

Prerequisites

  • Node-RED β‰₯ 4.0 β€” declared in package.json's node-red.version and enforced by the editor's Palette Manager.
  • Node.js β‰₯ 18.5 β€” Node-RED 4.x's effective minimum.
  • npm β€” only required when you use the libs config field to install user packages at deploy time. The portal itself does not need npm available at runtime.
  • A WebSocket-capable reverse proxy or direct connection β€” the portal upgrades /fromcubes/<sub-path>/_ws. If you front Node-RED with nginx / Traefik / Caddy, enable Upgrade: websocket headers on that path.

Install

cd ~/.node-red
npm install @aaqu/fromcubes-portal-react@alpha
# restart Node-RED

That's it. No build step. Any npm packages you list in the node config (e.g. d3, three) are installed automatically on deploy.

Your first portal

  1. Drop a portal-react node onto a flow.
  2. Set Sub-path to e.g. hello (the node will serve at /fromcubes/hello; the /fromcubes/ prefix is fixed).
  3. Open the code editor and paste:
    function App() {
      const { data, send } = useNodeRed();
      return (
        <div className="p-4">
          <p>From flow: {JSON.stringify(data)}</p>
          <button
            className="px-3 py-1 bg-blue-500 text-white rounded"
            onClick={() => send({ clicked: Date.now() })}
          >
            click me
          </button>
        </div>
      );
    }
    
  4. Deploy. Open http://localhost:1880/fromcubes/hello.
  5. Wire an inject node into the portal-react input β†’ see data update live. Wire its output β†’ see button clicks arrive in your flow.

The useNodeRed() hook

Everything your component needs from the flow lives in one hook:

const {
  data,          // last broadcast msg.payload from input wire (reactive)
  send,          // send(payload, topic?) β€” emit msg on output wire
  user,          // portal user object (when Portal Auth is enabled), or null
  portalClient,  // unique session/tab ID assigned by server on connect
  authRequired,  // true when this session is anonymous on an authenticated-only portal
} = useNodeRed();

Initial data on connect

The server does not cache payloads β€” a freshly-loaded page has data === null until the flow pushes the next message. For periodic streams that's at most one interval of blankness. For event-driven or per-user data, request the initial state from the component:

const { data, send } = useNodeRed();
useEffect(() => { send({}, "init"); }, []);

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.

Node configuration

Field Purpose
Sub-path Part after /fromcubes/, e.g. page1 β†’ served at /fromcubes/page1. Required. Nesting allowed (team/alpha). Reserved: public, _ws.
Page Title Browser tab title
npm Packages Comma-separated, e.g. d3, three, @react-three/fiber
Portal Auth Enable portal user header extraction (see Multi-user)
Authenticated-only delivery Untargeted msgs (no msg._client) go only to sessions with portal identity headers; anonymous sessions get an auth_required frame on connect (see Multi-user). Off by default.
Show WS status Small "fromcubes β€’ connected/disconnected" badge in the page's bottom-right corner (off by default)
Head HTML Extra trusted-author <head> tags (CDN, fonts, CSS, scripts). Runs in the public portal page.
Code Editor Monaco with JSX β€” must define <App />

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.

For shared non-component code (helpers, custom hooks, constants), use fc-portal-utility β€” a sibling config node. Unlike component nodes (which export exactly one symbol via an IIFE wrapper), a utility node is injected raw at top level so it can declare any number of function / const / let / class symbols. Selective inclusion: a utility node lands in a portal's bundle only when the portal's JSX or any of its referenced library components mentions at least one of the symbols declared in that utility.

// fc-portal-utility, Module name = mathHelpers
const PI2 = Math.PI * 2;
function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
function useDebounce(value, ms = 300) {
  const [v, setV] = React.useState(value);
  React.useEffect(() => {
    const t = setTimeout(() => setV(value), ms);
    return () => clearTimeout(t);
  }, [value, ms]);
  return v;
}

// portal-react, JSX tab
function App() {
  const { data } = useNodeRed();
  const slow = useDebounce(data?.value);
  return <div>{clamp(slow ?? 0, 0, 100)}</div>;
}

Editor features

  • Monaco with full JSX support and useNodeRed() type declarations
  • Tailwind CSS autocompletion inside className="..." (~19k utility classes). Classes must be complete literals in a string β€” dynamic bg-${tone}-500 generates no CSS (use a map of full classes). Arbitrary values incl. w-[calc(100%-2rem)], grid-cols-[repeat(2,1fr)], mt-0! and @md:flex are supported.
  • JSX tag completion β€” type tag name, Tab to expand
  • Self-close collapse β€” type / inside empty <tag></tag> to convert to <tag />
  • Ctrl+D / Cmd+D β€” duplicate the current line or selection (in every Monaco editor of the package)
  • Ctrl+/ / Cmd+/ β€” JSX-aware comment toggle. Style follows the first selected line (VS Code behavior): selecting a whole return ( … ) block gives plain // on every line, while lines between live JSX tags are wrapped in {/* … */} (a plain // there is literal text β€” JSX renders it on the page)
  • Component completion β€” registry components + any PascalCase word
  • Utility-symbol completion β€” top-level identifiers from any fc-portal-utility node, suggested in JS context
  • Components / Utilities dialogs β€” buttons in the JSX tab; Components inserts <Tag></Tag>, Utilities expands to the symbols declared in each node and inserts the bare identifier on click
  • Portal Assets sidebar β€” file manager for static assets (GLB, textures, fonts…)

Multi-user / Multi-tenancy

Portal-react has four routing modes β€” broadcast, auth-cast (every authenticated session), user-cast (every tab of one user), and unicast (one specific tab). Everything works without authentication too β€” user-cast and auth-cast just degrade gracefully when there is no user.

Identity

Identifier Scope Persistence Source
portalClient Single WS session (one tab) Lost on reconnect β€” server assigns a new UUID Generated server-side on connect
userId / username All sessions of a user Survives reconnect and new tabs x-portal-user-* headers (when Portal Auth is enabled)

Portal Auth header contract β€” injected by an upstream reverse proxy such as aaqu-portal-auth:

Header Field
x-portal-user-id userId
x-portal-user-name userName
x-portal-user-username username
x-portal-user-email email
x-portal-user-role role
x-portal-user-groups groups (JSON array)

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.

Routing modes

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:

// BROADCAST β€” everyone connected to this endpoint
delete msg._client;
return msg;

// UNICAST β€” only the tab that sent the original msg (echo)
// Keep msg._client as-is; it already carries portalClient.
return msg;

// UNICAST β€” a specific tab whose ID you know
msg._client = { portalClient: "a1b2c3d4-..." };
return msg;

// USER-CAST β€” every tab of a specific user (even ones that just opened)
msg._client = { userId: "alice" };
return msg;

// AUTH-CAST β€” every session that arrived with x-portal-user-* identity;
// anonymous sessions (no proxy headers) are skipped
msg._client = { authenticated: true };
return msg;

Auth-cast vs broadcast. Broadcast reaches every connected client, including anonymous ones. Auth-cast reaches only sessions that arrived with x-portal-user-* identity headers. Use auth-cast for data that any logged-in user may see; use broadcast only for data that is genuinely public. Nothing is cached server-side β€” each mode delivers to the clients connected at send time.

Authenticated-only delivery (fail-safe default)

With the Authenticated-only delivery checkbox enabled on the node, an untargeted msg (no msg._client) is delivered as an auth-cast instead of a broadcast β€” a forgotten _client then narrows delivery to logged-in sessions rather than leaking to anonymous ones. A true broadcast stays available explicitly:

msg._client = { authenticated: false };  // deliberate broadcast, anonymous included
return msg;

Anonymous sessions are told up front: right after hello the server sends a single auth_required frame (once per connection β€” never per skipped message, so unauthenticated clients learn nothing about traffic volume or timing). The hook exposes it so the page can render a login hint instead of staying silently empty:

const { data, authRequired } = useNodeRed();
if (authRequired) return <p className="p-4 text-zinc-500">Log in to see this data.</p>;

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.

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.

Without a user (anonymous mode)

If Portal Auth is off (or no proxy headers arrive), everything still works:

  • broadcast and portalClient unicast: unchanged
  • user-cast: gracefully skipped (no userId to target)
  • auth-cast: delivers to nobody (no session has identity headers)
  • useNodeRed().user is null

Same model as dashboard 2 β€” no auth required to use the node.

Portal Assets

Static files (3D models, textures, fonts, etc.) can be uploaded and served from a public endpoint.

  • Open the Portal Assets tab in the Node-RED sidebar
  • Upload files via button or drag & drop
  • Organize in folders (create, rename, move between folders)
  • Copy public path with one click β€” use in JSX: /fromcubes/public/models/scene.glb
  • Download and delete files from the context menu

All uploads require Node-RED admin authentication. Files are served publicly at /fromcubes/public/.

Limits: 100 MB per file, 500 MB total, 1000 files max.

Examples

Available via Menu β†’ Import β†’ Examples β†’ @aaqu/fromcubes-portal-react. Each flow includes a comment node with what to expect and the URL to open.

Import Shared Components first β€” it provides the UI building blocks (Page, Header, Stat, Button, ValueBadge) referenced by the others.

Example npm packages Description
Shared Components β€” Reusable components: Page, Header, Stat, Button, ValueBadge
Sensor Portal β€” Sensor gauge with live WebSocket data
Live Chart chart.js/auto Live updating Chart.js charts
D3 Poland Map d3 Interactive SVG map of Poland (simulated data)
Three.js Scene three, @react-three/fiber, @react-three/drei 3D scene with Three.js
PixiJS Sprites pixi.js, @pixi/react Clickable bunny sprites with PixiJS
WebGPU Shader three WebGPU renderer + TSL animated shaders
Utility Hooks β€” fc-portal-utility demo: useDebounce custom hook + clamp helper

Troubleshooting

Symptom Likely cause
Red status "transpile error" + error page on the endpoint JSX syntax error β€” fix code, redeploy (cache invalidates automatically)
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.
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).
Yellow status "css-fail" Tailwind generation failed (usually an invalid class in JSX). Page still loads but unstyled. Fix the class, redeploy β€” the status clears on the next successful build.
Code editor stays blank / portal-react/vs/loader.js 404s Monaco is served live from the package's own node_modules at /portal-react/vs (no postinstall copy step). The 404 means RED.httpAdmin is mounted under a non-root httpAdminRoot the editor didn't account for, or monaco-editor failed to install. Reinstall the package and hard-refresh the editor (Cmd/Ctrl-Shift-R).
npm install @aaqu/fromcubes-portal-react ends with EACCES The Node-RED userDir/node_modules install path is not writable by the user running Node-RED. Re-run npm install from a shell with the right ownership.
Browser request /portal-react/css/<hash>.css returns 404 The portal's deploy hasn't produced a CSS bundle yet β€” open the editor, redeploy. If the URL is bookmarked from before a deploy, the hash is stale; reload the portal page itself.
WebSocket reconnects in an endless loop Reverse-proxy is not forwarding Upgrade: websocket on /fromcubes/<sub-path>/_ws. Check the proxy config β€” nginx needs proxy_set_header Upgrade $http_upgrade, Traefik needs the websocket middleware.
libs packages fail to install on deploy The user-installed npm packages declared in Libs install via Node-RED's dynamicModuleList mechanism, which needs network access from userDir and a writable node_modules. Behind a corporate proxy set npm config set proxy … for the Node-RED user.
Page loads but data stays null Nothing has been pushed since the page connected β€” payloads are not cached server-side. Push periodically, or request initial state from the component (send({}, "init") β†’ flow replies)
user is null even with Portal Auth on Upstream proxy is not injecting x-portal-user-* headers
Page reloads on every deploy Expected for code changes; clients soft-reconnect with exponential backoff

Architecture: source AND sink

The Node-RED guidelines suggest a node should "sit at the beginning, middle or end of a flow β€” not all at once". portal-react intentionally violates this: every node is both a sink (msg β†’ render) and a source (UI event β†’ outbound msg). Trade-offs:

  • Pro: one wire per portal is conceptually simpler β€” the same node owns the page lifecycle, its WebSocket, and the routing rules.
  • Con: flow-error tracking only follows the input β†’ output path. Errors originating in the browser (e.g. a runtime exception inside a UI handler) cross the WebSocket as a source-event and are not routed to a Catch node on the same tab β€” they surface as a red node status and a log line. This matches the Node-RED guidance for source nodes: "produces messages in response to external events".

If you need explicit Catch-node visibility for UI-originated errors, wire the portal's output through a Switch / Catch chain β€” a UI event landing on msg.payload is then a regular wire-level message that Catch can observe.

Security

This module is meant to run behind a trusted Node-RED instance, ideally behind a reverse proxy. Highlights:

  • Admin write endpoints require the Node-RED-API-Version header (CSRF protection) and a portal-react.write permission when adminAuth is configured.
  • Token-bucket rate-limit (default 60 burst / 1 req/s steady) on every write endpoint, tunable via RED.settings.portalReact.rateLimit.
  • 1 MB JSON body cap, 100 MB asset-upload cap, 500 MB / 1000 files total assets quota, 1 MB WebSocket frame cap.
  • x-portal-user-* identity headers are trusted unconditionally β€” production deployments must terminate auth at a reverse proxy that strips inbound identity headers before injecting verified ones.

License

Apache-2.0

Node Info

Version: 0.1.0-alpha.35
Updated 1 week, 6 days ago
License: Apache-2.0
Rating: not yet rated

Categories

Actions

Rate:

Downloads

651 in the last week

Nodes

  • fc-portal-component
  • fc-portal-utility
  • portal-react

Keywords

  • node-red
  • dashboard
  • portal
  • jsx
  • react
  • tailwind
  • websocket
  • fromcubes

Maintainers