@aaqu/fromcubes-react 0.1.0 → 0.2.1

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
@@ -6,20 +6,19 @@
6
6
  [![Node-RED](https://img.shields.io/badge/Node--RED-%E2%89%A5%204.0-8f0000.svg)](https://nodered.org)
7
7
  [![license](https://img.shields.io/npm/l/%40aaqu%2Ffromcubes-react.svg)](./LICENSE)
8
8
  [![Discord](https://img.shields.io/badge/Discord-join%20chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/KYzsfKkYxX)
9
+ [![Sponsor](https://img.shields.io/badge/Sponsor-GitHub-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/Aaqu)
9
10
 
10
11
  **📖 Documentation — step-by-step guide with screenshots:** [aaqu.github.io/fromcubes-modules](https://aaqu.github.io/fromcubes-modules/) — install → import → edit JSX → live dashboard, on one example.
11
12
 
12
13
  [![Documentation walkthrough](https://aaqu.github.io/fromcubes-modules/img/docs-scroll.gif)](https://aaqu.github.io/fromcubes-modules/)
13
14
 
14
- > **⚠️ Alpha Module** — This project is in early development. Expect breaking changes. Test on a clean Node-RED instance.
15
-
16
15
  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.
17
16
 
18
17
  For internals, plugin authoring, and the deploy pipeline see [README-DEV.md](./README-DEV.md).
19
18
 
20
19
  **💬 Questions, feedback, showcase?** Join the [fromcubes Discord](https://discord.gg/KYzsfKkYxX).
21
20
 
22
- [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/L4L01UOFRG)
21
+ **❤️ Support the project:** [![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/Aaqu)
23
22
 
24
23
  ## Prerequisites
25
24
 
@@ -41,12 +40,17 @@ npm install @aaqu/fromcubes-react
41
40
  # restart Node-RED
42
41
  ```
43
42
 
44
- That's it. No build step. Any npm packages you list in the node config (e.g. `d3, three`) are installed automatically on deploy.
43
+ That's it — this is the whole core: the nodes, deploy-time bundling, the WebSocket bridge and the React Editor. No build step. Any npm packages you list in the node config (e.g. `d3, three`) are installed automatically on deploy.
45
44
 
46
45
  ### Add-ons
47
46
 
48
- Three parts ship as their own packages, and for now installing the core
49
- installs them too:
47
+ Everything else is an add-on you install only if you need it:
48
+
49
+ ```bash
50
+ cd ~/.node-red
51
+ npm install @aaqu/fromcubes-tailwind # and/or -assets, -auth-headers
52
+ # restart Node-RED
53
+ ```
50
54
 
51
55
  | Package | What it adds |
52
56
  |---|---|
@@ -54,10 +58,29 @@ installs them too:
54
58
  | [`@aaqu/fromcubes-auth-headers`](https://www.npmjs.com/package/@aaqu/fromcubes-auth-headers) | Users from `x-portal-user-*` headers set by a reverse proxy (Portal Auth) |
55
59
  | [`@aaqu/fromcubes-assets`](https://www.npmjs.com/package/@aaqu/fromcubes-assets) | Static files for portals, managed from a sidebar tab |
56
60
 
57
- Each is a Node-RED plugin built on the same API anyone can use — see
58
- [Plugins](#plugins-identity-providers-and-css-engines). Coming from
59
- `@aaqu/fromcubes-portal-react`? Uninstall it first, then install this
60
- package: both register the same nodes. Your flows keep working unchanged.
61
+ Without an add-on its feature is simply off, and the editor says so once in
62
+ the log: no Tailwind means portals get no generated stylesheet (style them
63
+ with a `<link>`/`<style>` in **Head HTML**, or inline), no auth-headers means
64
+ Portal Auth sees every session as anonymous, no assets means no sidebar and no
65
+ `/fromcubes/public/`. Install an add-on later and it switches on by itself —
66
+ **Menu → Settings → Fromcubes** shows what is in use.
67
+
68
+ The shipped examples are styled with Tailwind; install
69
+ `@aaqu/fromcubes-tailwind` to see them as intended.
70
+
71
+ Each add-on is a Node-RED plugin built on the same API anyone can use — see
72
+ [Plugins](#plugins-identity-providers-and-css-engines).
73
+
74
+ **Coming from `@aaqu/fromcubes-portal-react`?** It contained all of the above.
75
+ Uninstall it first (both register the same nodes), then install the core
76
+ **and the add-ons you used** — for the same features as before:
77
+
78
+ ```bash
79
+ npm uninstall @aaqu/fromcubes-portal-react
80
+ npm install @aaqu/fromcubes-react @aaqu/fromcubes-tailwind @aaqu/fromcubes-assets @aaqu/fromcubes-auth-headers
81
+ ```
82
+
83
+ Your flows keep working unchanged.
61
84
 
62
85
  ## Your first portal
63
86
 
@@ -401,6 +424,13 @@ behind a reverse proxy. Highlights:
401
424
  inbound identity headers before injecting verified ones, and Node-RED must
402
425
  not be reachable except through that proxy.
403
426
 
427
+ ## Support
428
+
429
+ fromcubes is built and maintained by one person. If it saves you time,
430
+ consider sponsoring its development:
431
+
432
+ [![Sponsor on GitHub](https://img.shields.io/badge/Sponsor_on-GitHub-ea4aaa?style=for-the-badge&logo=githubsponsors&logoColor=white)](https://github.com/sponsors/Aaqu)
433
+
404
434
  ## License
405
435
 
406
436
  Apache-2.0
@@ -29,8 +29,8 @@
29
29
  "id": "fc-pixi-comp-statusbar",
30
30
  "type": "fc-portal-component",
31
31
  "z": "fc-pixi-flow",
32
- "compName": "StatusBar",
33
- "compCode": "function StatusBar({ spriteCount, totalClicks, lastClicked }) {\n return (\n <div className=\"px-6 py-4 bg-zinc-900/50 border-t border-zinc-800/50 flex items-center gap-6 flex-wrap\">\n <Stat label=\"Sprites\">\n <span className=\"text-sm font-mono text-cyan-400\">{spriteCount}</span>\n </Stat>\n <Stat label=\"Total clicks\">\n <span className=\"text-sm font-mono text-emerald-400\">{totalClicks}</span>\n </Stat>\n <Stat label=\"Last clicked\">\n <span className=\"text-sm font-mono text-zinc-300\">{lastClicked || '\\u2014'}</span>\n </Stat>\n </div>\n );\n}",
32
+ "compName": "PixiStatusBar",
33
+ "compCode": "function PixiStatusBar({ spriteCount, totalClicks, lastClicked }) {\n return (\n <div className=\"px-6 py-4 bg-zinc-900/50 border-t border-zinc-800/50 flex items-center gap-6 flex-wrap\">\n <Stat label=\"Sprites\">\n <span className=\"text-sm font-mono text-cyan-400\">{spriteCount}</span>\n </Stat>\n <Stat label=\"Total clicks\">\n <span className=\"text-sm font-mono text-emerald-400\">{totalClicks}</span>\n </Stat>\n <Stat label=\"Last clicked\">\n <span className=\"text-sm font-mono text-zinc-300\">{lastClicked || '\\u2014'}</span>\n </Stat>\n </div>\n );\n}",
34
34
  "x": 360,
35
35
  "y": 300,
36
36
  "wires": []
@@ -91,7 +91,7 @@
91
91
  "var": "PixiReact"
92
92
  }
93
93
  ],
94
- "componentCode": "import { Sprite } from 'pixi.js';\nimport { Application, extend } from '@pixi/react';\nextend({ Sprite });\n\nfunction App() {\n const { data, send } = useNodeRed();\n const [sprites, setSprites] = useState([]);\n const [totalClicks, setTotalClicks] = useState(0);\n const [lastClicked, setLastClicked] = useState(null);\n const idRef = useRef(0);\n\n useEffect(() => {\n if (!data) return;\n setSprites(prev => {\n const next = [...prev, {\n id: idRef.current++,\n x: 40 + Math.random() * 520,\n y: 40 + Math.random() * 320,\n tint: data.tint || 0x22d3ee\n }];\n return next.length > 12 ? next.slice(-12) : next;\n });\n }, [data]);\n\n const handleClick = useCallback((info) => {\n setTotalClicks(c => c + 1);\n setLastClicked('#' + info.id);\n send({\n action: 'clicked',\n spriteId: info.id,\n x: Math.round(info.x),\n y: Math.round(info.y),\n spriteClicks: info.clicks,\n timestamp: Date.now()\n }, 'pixi/click');\n }, [send]);\n\n return (\n <Page>\n <Header title=\"fromcubes Pixi\" subtitle=\"click the bunnies\" />\n <div className=\"flex-1 flex items-center justify-center p-6\">\n <div className=\"rounded-xl overflow-hidden border border-zinc-800\">\n <Application width={600} height={400} background={0x18181b} antialias>\n {sprites.map(s => (\n <ClickableSprite key={s.id} {...s} onClick={handleClick} />\n ))}\n </Application>\n </div>\n </div>\n <StatusBar spriteCount={sprites.length} totalClicks={totalClicks} lastClicked={lastClicked} />\n </Page>\n );\n}",
94
+ "componentCode": "import { Sprite } from 'pixi.js';\nimport { Application, extend } from '@pixi/react';\nextend({ Sprite });\n\nfunction App() {\n const { data, send } = useNodeRed();\n const [sprites, setSprites] = useState([]);\n const [totalClicks, setTotalClicks] = useState(0);\n const [lastClicked, setLastClicked] = useState(null);\n const idRef = useRef(0);\n\n useEffect(() => {\n if (!data) return;\n setSprites(prev => {\n const next = [...prev, {\n id: idRef.current++,\n x: 40 + Math.random() * 520,\n y: 40 + Math.random() * 320,\n tint: data.tint || 0x22d3ee\n }];\n return next.length > 12 ? next.slice(-12) : next;\n });\n }, [data]);\n\n const handleClick = useCallback((info) => {\n setTotalClicks(c => c + 1);\n setLastClicked('#' + info.id);\n send({\n action: 'clicked',\n spriteId: info.id,\n x: Math.round(info.x),\n y: Math.round(info.y),\n spriteClicks: info.clicks,\n timestamp: Date.now()\n }, 'pixi/click');\n }, [send]);\n\n return (\n <Page>\n <Header title=\"fromcubes Pixi\" subtitle=\"click the bunnies\" />\n <div className=\"flex-1 flex items-center justify-center p-6\">\n <div className=\"rounded-xl overflow-hidden border border-zinc-800\">\n <Application width={600} height={400} background={0x18181b} antialias>\n {sprites.map(s => (\n <ClickableSprite key={s.id} {...s} onClick={handleClick} />\n ))}\n </Application>\n </div>\n </div>\n <PixiStatusBar spriteCount={sprites.length} totalClicks={totalClicks} lastClicked={lastClicked} />\n </Page>\n );\n}",
95
95
  "x": 560,
96
96
  "y": 160,
97
97
  "wires": [
@@ -29,8 +29,8 @@
29
29
  "id": "fc-three-comp-statusbar",
30
30
  "type": "fc-portal-component",
31
31
  "z": "fc-three-flow",
32
- "compName": "StatusBar",
33
- "compCode": "function StatusBar({ leftColor, rightColor, clickCount }) {\n return (\n <div className=\"px-6 py-4 bg-zinc-900/50 border-t border-zinc-800/50 flex items-center gap-6 flex-wrap\">\n <Stat label=\"Left box\">\n <span className=\"w-4 h-4 rounded-full border border-zinc-700\" style={{ backgroundColor: leftColor }} />\n </Stat>\n <Stat label=\"Right box\">\n <span className=\"w-4 h-4 rounded-full border border-zinc-700\" style={{ backgroundColor: rightColor }} />\n </Stat>\n <Stat label=\"Clicks\">\n <span className=\"text-sm font-mono text-cyan-400\">{clickCount}</span>\n </Stat>\n </div>\n );\n}",
32
+ "compName": "ThreeStatusBar",
33
+ "compCode": "function ThreeStatusBar({ leftColor, rightColor, clickCount }) {\n return (\n <div className=\"px-6 py-4 bg-zinc-900/50 border-t border-zinc-800/50 flex items-center gap-6 flex-wrap\">\n <Stat label=\"Left box\">\n <span className=\"w-4 h-4 rounded-full border border-zinc-700\" style={{ backgroundColor: leftColor }} />\n </Stat>\n <Stat label=\"Right box\">\n <span className=\"w-4 h-4 rounded-full border border-zinc-700\" style={{ backgroundColor: rightColor }} />\n </Stat>\n <Stat label=\"Clicks\">\n <span className=\"text-sm font-mono text-cyan-400\">{clickCount}</span>\n </Stat>\n </div>\n );\n}",
34
34
  "x": 360,
35
35
  "y": 300,
36
36
  "wires": []
@@ -95,7 +95,7 @@
95
95
  "var": "Drei"
96
96
  }
97
97
  ],
98
- "componentCode": "import { Canvas } from '@react-three/fiber';\nimport { OrbitControls } from '@react-three/drei';\n\nfunction App() {\n const { data, send } = useNodeRed();\n const colors = data || { leftColor: 'orange', rightColor: 'cyan' };\n const [clickCount, setClickCount] = useState(0);\n\n const handleToggle = useCallback((clicked) => {\n setClickCount(prev => prev + 1);\n send({ action: clicked ? 'clicked' : 'unclicked', clicks: clickCount + 1 }, '3d/interact');\n }, [send, clickCount]);\n\n return (\n <div className=\"h-screen bg-zinc-950 text-zinc-100 flex flex-col\">\n <Header title=\"fromcubes 3D\" subtitle=\"React Three Fiber + live Node-RED data\" />\n <div className=\"flex-1 overflow-hidden\">\n <Canvas style={{ width: '100%', height: '100%' }}>\n <ambientLight intensity={Math.PI / 2} />\n <spotLight position={[10, 10, 10]} angle={0.15} penumbra={1} decay={0} intensity={Math.PI} />\n <pointLight position={[-10, -10, -10]} decay={0} intensity={Math.PI} />\n <Box position={[-1.2, 0, 0]} color={colors.leftColor} onToggle={handleToggle} />\n <Box position={[1.2, 0, 0]} color={colors.rightColor} onToggle={handleToggle} />\n <OrbitControls />\n </Canvas>\n </div>\n <StatusBar leftColor={colors.leftColor} rightColor={colors.rightColor} clickCount={clickCount} />\n </div>\n );\n}",
98
+ "componentCode": "import { Canvas } from '@react-three/fiber';\nimport { OrbitControls } from '@react-three/drei';\n\nfunction App() {\n const { data, send } = useNodeRed();\n const colors = data || { leftColor: 'orange', rightColor: 'cyan' };\n const [clickCount, setClickCount] = useState(0);\n\n const handleToggle = useCallback((clicked) => {\n setClickCount(prev => prev + 1);\n send({ action: clicked ? 'clicked' : 'unclicked', clicks: clickCount + 1 }, '3d/interact');\n }, [send, clickCount]);\n\n return (\n <div className=\"h-screen bg-zinc-950 text-zinc-100 flex flex-col\">\n <Header title=\"fromcubes 3D\" subtitle=\"React Three Fiber + live Node-RED data\" />\n <div className=\"flex-1 overflow-hidden\">\n <Canvas style={{ width: '100%', height: '100%' }}>\n <ambientLight intensity={Math.PI / 2} />\n <spotLight position={[10, 10, 10]} angle={0.15} penumbra={1} decay={0} intensity={Math.PI} />\n <pointLight position={[-10, -10, -10]} decay={0} intensity={Math.PI} />\n <Box position={[-1.2, 0, 0]} color={colors.leftColor} onToggle={handleToggle} />\n <Box position={[1.2, 0, 0]} color={colors.rightColor} onToggle={handleToggle} />\n <OrbitControls />\n </Canvas>\n </div>\n <ThreeStatusBar leftColor={colors.leftColor} rightColor={colors.rightColor} clickCount={clickCount} />\n </div>\n );\n}",
99
99
  "x": 560,
100
100
  "y": 160,
101
101
  "wires": [
@@ -7,7 +7,7 @@
7
7
  * module is a pair of pure functions so it can be unit-tested under vitest and
8
8
  * still be loaded as a plain browser script by the Node-RED editor.
9
9
  *
10
- * Loaded in the browser via `<script src="fromcubes-react/editor/file-tree.js">`
10
+ * Loaded in the browser by the asset loader in portal-react.html (`<base>/editor/file-tree.js`)
11
11
  * (exposes `window.__fcFileTree`) and in tests via `require()`.
12
12
  */
13
13
  (function (root, factory) {
@@ -13,7 +13,7 @@
13
13
  "use strict";
14
14
 
15
15
  const PREFIX = "[FC-Format]";
16
- const BASE = "fromcubes-react/prettier/";
16
+ const BASE = (window.__fcAssetBase || "fromcubes-react/") + "prettier/";
17
17
  // UMD builds: each sets window.prettier / window.prettierPlugins.
18
18
  const SCRIPTS = [
19
19
  "standalone.js",
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Settings → Fromcubes: choose the identity provider and the CSS engine.
2
+ * Settings → Fromcubes: choose the CSS engine and the identity provider.
3
3
  *
4
4
  * Exactly one provider per kind, shown as a radio list, so two can never be
5
5
  * active at once. Reads and writes `/fromcubes-react/providers`; the server
6
6
  * validates, persists to <userDir>/fromcubes/providers.json, and applies the
7
- * change (identity: every portal session reloads; css: every portal rebuilds).
7
+ * change (css: every portal rebuilds; identity: every portal session reloads).
8
8
  * A kind set in settings.js is shown locked.
9
9
  *
10
10
  * Node-RED shows a pane by the element id `red-ui-settings-tab-<pane id>`,
@@ -18,18 +18,18 @@
18
18
  var URL = "fromcubes-react/providers";
19
19
 
20
20
  var KINDS = [
21
- {
22
- kind: "identity",
23
- title: "Identity provider",
24
- hint: "Who is behind a portal session — used by Portal Auth, auth-cast and per-user delivery.",
25
- setting: "identityProvider",
26
- },
27
21
  {
28
22
  kind: "css",
29
23
  title: "CSS engine",
30
24
  hint: "Generates each portal's stylesheet at deploy. Changing it rebuilds every portal.",
31
25
  setting: "cssEngine",
32
26
  },
27
+ {
28
+ kind: "identity",
29
+ title: "Identity provider",
30
+ hint: "Who is behind a portal session — used by Portal Auth, auth-cast and per-user delivery.",
31
+ setting: "identityProvider",
32
+ },
33
33
  ];
34
34
 
35
35
  function errorText(xhr) {
@@ -630,6 +630,13 @@ body.fc-ws-open #red-ui-notifications {
630
630
  /* ── Settings → Fromcubes (provider-settings.js) ─────────────────
631
631
  Lives in Node-RED's own Settings tray, not in the overlay, so it uses
632
632
  Node-RED's theme variables instead of the vs-dark palette above. */
633
+ /* Same box as Node-RED's own View pane (#red-ui-settings-tab-view): fills
634
+ the content area, scrolls on its own, 20px side padding. */
635
+ .fc-ps {
636
+ position: absolute; top: 0; right: 0; bottom: 0; left: 0;
637
+ padding: 8px 20px 20px;
638
+ overflow-y: auto;
639
+ }
633
640
  .fc-ps .fc-ps-section { margin-bottom: 24px; }
634
641
  .fc-ps .fc-ps-hint { color: var(--red-ui-secondary-text-color); margin: 0 0 8px; }
635
642
  .fc-ps .fc-ps-note,
@@ -19,6 +19,8 @@
19
19
  const EDITOR_NAME = "React Editor";
20
20
  const FLUSH_DEBOUNCE_MS = 400;
21
21
  const MIN_TREE_WIDTH = 150;
22
+ // Versioned asset path set by portal-react.html (see ASSET_BASE in lib/admin-api.js).
23
+ const ASSET_BASE = window.__fcAssetBase || "fromcubes-react/";
22
24
 
23
25
  // ── Module state ────────────────────────────────────────────
24
26
 
@@ -636,7 +638,7 @@
636
638
  * @returns {void}
637
639
  */
638
640
  function injectIconSprite() {
639
- $.ajax({ url: "fromcubes-react/editor/icons.svg", dataType: "text" })
641
+ $.ajax({ url: ASSET_BASE + "editor/icons.svg", dataType: "text" })
640
642
  .done(function (markup) {
641
643
  $("<div>")
642
644
  .attr("id", "fc-ws-icon-sprite")
@@ -1107,7 +1109,7 @@
1107
1109
  const brand = $("<div>").addClass("fc-ws-brand").appendTo(header);
1108
1110
  $("<img>")
1109
1111
  .addClass("fc-ws-mark-logo")
1110
- .attr("src", "fromcubes-react/editor/mark.svg")
1112
+ .attr("src", ASSET_BASE + "editor/mark.svg")
1111
1113
  .attr("alt", "fromcubes")
1112
1114
  .appendTo(brand);
1113
1115
  // One word split by colour — wrapped so the lockup's gap sits between the
@@ -1471,7 +1473,7 @@
1471
1473
  });
1472
1474
  $("<img>")
1473
1475
  .addClass("fc-ws-statusbar-icon")
1474
- .attr("src", "fromcubes-react/icons/fromcubes-react.svg")
1476
+ .attr("src", ASSET_BASE + "icons/fromcubes-react.svg")
1475
1477
  .attr("alt", "")
1476
1478
  .appendTo(el);
1477
1479
  $("<span>").text(EDITOR_NAME).appendTo(el);
@@ -1,5 +1,21 @@
1
1
  const path = require("path");
2
2
 
3
+ /**
4
+ * Editor assets are also served under a path carrying the core's version
5
+ * (`fromcubes-react/0.2.0/editor/…`), and the editor template loads them from
6
+ * there. Every cache in between — the browser, a CDN, a reverse proxy — keys
7
+ * on the path, so a new release can never be handed an old file, nor a 404
8
+ * remembered from a moment the route did not exist yet. Query strings are not
9
+ * enough: some caches ignore them. The unversioned paths stay for anything
10
+ * that still points at them.
11
+ */
12
+ const ASSET_VERSION = String(require("../../package.json").version).replace(
13
+ /[^0-9A-Za-z.+-]/g,
14
+ "",
15
+ );
16
+ /** Relative to the admin root, as the editor needs it (no leading slash). */
17
+ const ASSET_BASE = "fromcubes-react/" + ASSET_VERSION + "/";
18
+
3
19
  /**
4
20
  * Register editor/admin/public support routes for portal-react.
5
21
  *
@@ -37,41 +53,36 @@ function registerAdminApi(RED, deps) {
37
53
  onProviderChange,
38
54
  } = deps;
39
55
 
56
+ /**
57
+ * Mount one static directory at both its plain and its versioned path.
58
+ * @param {string} name
59
+ * @param {string} dir
60
+ * @returns {void}
61
+ */
62
+ function serveStatic(name, dir) {
63
+ const files = express.static(dir);
64
+ RED.httpAdmin.use("/fromcubes-react/" + name, permRead, files);
65
+ RED.httpAdmin.use("/" + ASSET_BASE + name, permRead, files);
66
+ }
67
+
40
68
  // Since 0.56 monaco-editor has an `exports` map that hides package.json;
41
69
  // its CommonJS entry is min/vs/index.js, so the AMD build (loader.js and
42
70
  // editor/editor.main.js) sits in the same directory.
43
- const monacoVsPath = path.dirname(require.resolve("monaco-editor"));
44
- RED.httpAdmin.use(
45
- "/fromcubes-react/vs",
46
- permRead,
47
- express.static(monacoVsPath),
48
- );
71
+ serveStatic("vs", path.dirname(require.resolve("monaco-editor")));
49
72
 
50
73
  // Editor-side workspace plugin (tree + full-screen Monaco overlay). Served
51
74
  // as static files instead of being inlined into portal-react.html so the
52
75
  // node's editor template stays readable.
53
- RED.httpAdmin.use(
54
- "/fromcubes-react/editor",
55
- permRead,
56
- express.static(path.join(__dirname, "..", "editor")),
57
- );
76
+ serveStatic("editor", path.join(__dirname, "..", "editor"));
58
77
 
59
78
  // The node icon doubles as the editor's status-bar mark. Serving the icons
60
79
  // directory here keeps one copy on disk and spares the client from having to
61
80
  // know the package name that Node-RED's own /icons route is keyed by.
62
- RED.httpAdmin.use(
63
- "/fromcubes-react/icons",
64
- permRead,
65
- express.static(path.join(__dirname, "..", "icons")),
66
- );
81
+ serveStatic("icons", path.join(__dirname, "..", "icons"));
67
82
 
68
83
  // Prettier's browser builds, straight from node_modules — same arrangement
69
84
  // as Monaco at /fromcubes-react/vs. Nothing is fetched until someone formats.
70
- RED.httpAdmin.use(
71
- "/fromcubes-react/prettier",
72
- permRead,
73
- express.static(path.dirname(require.resolve("prettier/package.json"))),
74
- );
85
+ serveStatic("prettier", path.dirname(require.resolve("prettier/package.json")));
75
86
 
76
87
  const CSS_HASH_RE = /^[a-f0-9]{1,64}$/;
77
88
  function findCssByHash(reqHash) {
@@ -235,4 +246,4 @@ function registerProviderRoutes(RED, deps) {
235
246
  );
236
247
  }
237
248
 
238
- module.exports = { registerAdminApi };
249
+ module.exports = { registerAdminApi, ASSET_BASE };
@@ -30,9 +30,13 @@
30
30
  * — the administrator's word; the editor shows it locked.
31
31
  * 2. The choice made in the editor (Settings → Fromcubes), persisted in
32
32
  * `<userDir>/fromcubes/providers.json`.
33
- * 3. The defaults, `headers` and `tailwind` — names, not code: when their
34
- * add-on is not installed the default is simply missing (identity:
35
- * anonymous; css: unstyled), exactly like any other missing provider.
33
+ * 3. The default: the official add-on when it is installed — `headers`
34
+ * from @aaqu/fromcubes-auth-headers, `tailwind` from
35
+ * @aaqu/fromcubes-tailwind — and `none` when it is not. The core works
36
+ * on its own; installing the add-on is what turns the feature on. Only
37
+ * the official package counts here: another package registering a
38
+ * plugin called `headers` never becomes the default identity provider
39
+ * (it can still be chosen explicitly, in 1 or 2).
36
40
  * Installing a plugin never switches anything on its own — for identity that
37
41
  * would let a package change who counts as logged in.
38
42
  *
@@ -50,6 +54,7 @@ const API_VERSION = 1;
50
54
 
51
55
  const DEFAULT_IDENTITY = "headers";
52
56
  const DEFAULT_CSS = "tailwind";
57
+ const NONE = "none";
53
58
 
54
59
  // Bounds on what a provider may hand us. Generous enough that anything the
55
60
  // built-in header parser produces passes untouched (its groups header is
@@ -68,8 +73,18 @@ const MAX_EDITOR_CLASS_LEN = 200;
68
73
  const USER_STRING_FIELDS = ["login", "displayName", "email", "role"];
69
74
 
70
75
  const KINDS = {
71
- identity: { type: IDENTITY_TYPE, setting: "identityProvider", fallback: DEFAULT_IDENTITY },
72
- css: { type: CSS_TYPE, setting: "cssEngine", fallback: DEFAULT_CSS },
76
+ identity: {
77
+ type: IDENTITY_TYPE,
78
+ setting: "identityProvider",
79
+ fallback: DEFAULT_IDENTITY,
80
+ fallbackModule: "@aaqu/fromcubes-auth-headers",
81
+ },
82
+ css: {
83
+ type: CSS_TYPE,
84
+ setting: "cssEngine",
85
+ fallback: DEFAULT_CSS,
86
+ fallbackModule: "@aaqu/fromcubes-tailwind",
87
+ },
73
88
  };
74
89
 
75
90
  // Provider ids travel through the settings file, the admin API and the
@@ -261,7 +276,24 @@ function createProviders(RED, opts = {}) {
261
276
  return { id: fromSettings, source: "settings" };
262
277
  }
263
278
  if (chosen[kind]) return { id: chosen[kind], source: "editor" };
264
- return { id: k.fallback, source: "default" };
279
+ const official = pluginsOf(k.type).some(
280
+ (p) => p.id === k.fallback && p.module === k.fallbackModule,
281
+ );
282
+ return { id: official ? k.fallback : NONE, source: "default", addonMissing: !official };
283
+ }
284
+
285
+ /**
286
+ * Log once, at info level, why a kind runs on `none` by default — so a
287
+ * core installed on its own explains itself instead of failing quietly.
288
+ * @param {string} kind
289
+ * @param {string} msg
290
+ * @returns {void}
291
+ */
292
+ function explainNone(kind, msg) {
293
+ const key = "none:" + kind;
294
+ if (logged.has(key)) return;
295
+ logged.add(key);
296
+ if (RED.log.info) RED.log.info("[portal-react] " + msg);
265
297
  }
266
298
 
267
299
  /**
@@ -428,7 +460,15 @@ function createProviders(RED, opts = {}) {
428
460
  * @returns {Promise<Object|null>}
429
461
  */
430
462
  async function resolveUser(req) {
431
- const id = identityId();
463
+ const cur = current("identity");
464
+ const id = cur.id;
465
+ // Called only for portals with Portal Auth on — so this is someone who
466
+ // expects users and gets none.
467
+ if (cur.addonMissing) {
468
+ explainNone("identity", "Portal Auth is on but no identity provider is installed, so every " +
469
+ "session is anonymous. For users from x-portal-user-* headers install " +
470
+ "@aaqu/fromcubes-auth-headers, or choose a provider in Settings → Fromcubes.");
471
+ }
432
472
  const { provider, error } = lookup(IDENTITY_TYPE, builtins.identity, id);
433
473
  if (!provider) {
434
474
  logOnce(error + " — every session is anonymous");
@@ -466,7 +506,12 @@ function createProviders(RED, opts = {}) {
466
506
  * @returns {Promise<{css: string}>}
467
507
  */
468
508
  async function generateCss(source) {
469
- const id = cssEngineId();
509
+ const cur = current("css");
510
+ const id = cur.id;
511
+ if (cur.addonMissing) {
512
+ explainNone("css", "No CSS engine is installed, so portals are served without a stylesheet. " +
513
+ "For Tailwind CSS install @aaqu/fromcubes-tailwind.");
514
+ }
470
515
  const { provider, error } = lookup(CSS_TYPE, builtins.css, id);
471
516
  if (!provider) throw new Error(error);
472
517
  if (typeof provider.generate !== "function") {
@@ -1,12 +1,27 @@
1
1
  <!-- ============================================================
2
2
  Portal Code Workspace — sidebar tab + full-screen editor overlay.
3
- Served from nodes/editor/ via the /fromcubes-react/editor admin route.
3
+ Served from nodes/editor/ via the /fromcubes-react/<version>/editor admin route.
4
4
  ============================================================ -->
5
- <link rel="stylesheet" href="fromcubes-react/editor/workspace.css" />
6
- <script type="text/javascript" src="fromcubes-react/editor/file-tree.js"></script>
7
- <script type="text/javascript" src="fromcubes-react/editor/workspace.js"></script>
8
- <script type="text/javascript" src="fromcubes-react/editor/format.js"></script>
9
- <script type="text/javascript" src="fromcubes-react/editor/provider-settings.js"></script>
5
+ <script type="text/javascript">
6
+ // Loaded from the versioned asset path the runtime exports (ASSET_BASE in
7
+ // lib/admin-api.js), so no cache — browser, CDN or proxy — can serve this
8
+ // template an older file, or a 404 it remembered. Execution order follows
9
+ // insertion order (async = false); every script waits for what it needs.
10
+ (function () {
11
+ const base = (RED.settings && RED.settings.portalReactAssetBase) || "fromcubes-react/";
12
+ window.__fcAssetBase = base;
13
+ const link = document.createElement("link");
14
+ link.rel = "stylesheet";
15
+ link.href = base + "editor/workspace.css";
16
+ document.head.appendChild(link);
17
+ ["file-tree.js", "workspace.js", "format.js", "provider-settings.js"].forEach(function (file) {
18
+ const s = document.createElement("script");
19
+ s.src = base + "editor/" + file;
20
+ s.async = false;
21
+ document.head.appendChild(s);
22
+ });
23
+ })();
24
+ </script>
10
25
 
11
26
  <!-- ============================================================
12
27
  Shared: Monaco loader (used by both node editors)
@@ -1041,10 +1056,11 @@
1041
1056
  return;
1042
1057
  }
1043
1058
  const s = document.createElement("script");
1044
- s.src = "fromcubes-react/vs/loader.js";
1059
+ const base = window.__fcAssetBase || "fromcubes-react/";
1060
+ s.src = base + "vs/loader.js";
1045
1061
  s.onload = function () {
1046
1062
  console.log(PREFIX, "loader.js loaded, configuring require paths");
1047
- require.config({ paths: { vs: "fromcubes-react/vs" } });
1063
+ require.config({ paths: { vs: base + "vs" } });
1048
1064
  require(["vs/editor/editor.main"], function () {
1049
1065
  console.log(PREFIX, "editor.main loaded");
1050
1066
  ensureJsxSetup();
@@ -1629,12 +1629,17 @@ module.exports = function (RED) {
1629
1629
  }); // end setImmediate
1630
1630
  }
1631
1631
 
1632
+ const { registerAdminApi, ASSET_BASE } = require("./lib/admin-api");
1632
1633
  RED.nodes.registerType("portal-react", PortalReactNode, {
1633
1634
  dynamicModuleList: "libs",
1635
+ // Read by portal-react.html to load the editor assets from their
1636
+ // versioned path (see ASSET_BASE in lib/admin-api.js).
1637
+ settings: {
1638
+ portalReactAssetBase: { value: ASSET_BASE, exportable: true },
1639
+ },
1634
1640
  });
1635
1641
 
1636
1642
  const express = loadExpress();
1637
- const { registerAdminApi } = require("./lib/admin-api");
1638
1643
  registerAdminApi(RED, {
1639
1644
  express,
1640
1645
  permRead: PERM_READ,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaqu/fromcubes-react",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Fromcubes React - React portals for Node-RED, built at deploy, with live WebSocket data binding",
5
5
  "type": "commonjs",
6
6
  "keywords": [
@@ -41,10 +41,7 @@
41
41
  "monaco-editor": "^0.56.0",
42
42
  "prettier": "^3.9.8",
43
43
  "react": "^19.3.0",
44
- "react-dom": "^19.3.0",
45
- "@aaqu/fromcubes-assets": "^0.1.0",
46
- "@aaqu/fromcubes-auth-headers": "^0.1.0",
47
- "@aaqu/fromcubes-tailwind": "^0.1.0"
44
+ "react-dom": "^19.3.0"
48
45
  },
49
46
  "peerDependencies": {
50
47
  "ws": "^8.21.0"
@@ -64,8 +61,8 @@
64
61
  },
65
62
  "homepage": "https://aaqu.github.io/fromcubes-modules/",
66
63
  "funding": {
67
- "type": "ko-fi",
68
- "url": "https://ko-fi.com/L4L01UOFRG"
64
+ "type": "github",
65
+ "url": "https://github.com/sponsors/Aaqu"
69
66
  },
70
67
  "license": "Apache-2.0",
71
68
  "author": {