@aaqu/fromcubes-react 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +405 -2
  3. package/THIRD-PARTY-NOTICES.md +164 -0
  4. package/examples/D3 Poland Map.json +90 -0
  5. package/examples/Live Chart.json +101 -0
  6. package/examples/PixiJS Sprites.json +114 -0
  7. package/examples/Sensor Portal.json +93 -0
  8. package/examples/Shared Components.json +68 -0
  9. package/examples/Three.js Scene.json +118 -0
  10. package/examples/Utility Hooks.json +94 -0
  11. package/examples/WebGPU Shader.json +110 -0
  12. package/nodes/editor/ark-pixel.LICENSE.txt +96 -0
  13. package/nodes/editor/ark-pixel.woff2 +0 -0
  14. package/nodes/editor/file-tree.js +403 -0
  15. package/nodes/editor/format.js +182 -0
  16. package/nodes/editor/icons.svg +46 -0
  17. package/nodes/editor/mark.svg +5 -0
  18. package/nodes/editor/provider-settings.js +192 -0
  19. package/nodes/editor/workspace.css +653 -0
  20. package/nodes/editor/workspace.js +1565 -0
  21. package/nodes/icons/fromcubes-component.svg +27 -0
  22. package/nodes/icons/fromcubes-react.svg +25 -0
  23. package/nodes/icons/fromcubes-utility.svg +21 -0
  24. package/nodes/lib/admin-api.js +238 -0
  25. package/nodes/lib/admin-security.js +161 -0
  26. package/nodes/lib/api.js +29 -0
  27. package/nodes/lib/helpers.js +990 -0
  28. package/nodes/lib/hooks.js +145 -0
  29. package/nodes/lib/page-builder.js +499 -0
  30. package/nodes/lib/portal-page-route.js +131 -0
  31. package/nodes/lib/providers.js +536 -0
  32. package/nodes/lib/registry-nodes.js +331 -0
  33. package/nodes/lib/router.js +103 -0
  34. package/nodes/lib/ws-heartbeat.js +58 -0
  35. package/nodes/portal-react.html +2539 -0
  36. package/nodes/portal-react.js +1652 -0
  37. package/package.json +76 -4
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Public portal page route factory.
3
+ *
4
+ * Owns only the HTTP response branching for a portal endpoint:
5
+ * building/teardown, hard build error, degraded last-good build, and fresh
6
+ * successful build. Runtime lifecycle, WebSocket handling, and pageState
7
+ * mutation stay in portal-react.js.
8
+ */
9
+
10
+ function buildBuildingPage(wsPath) {
11
+ return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Building\u2026</title><style>@keyframes __sp{to{transform:rotate(360deg)}}body{font-family:monospace;background:#111;color:#888;margin:0;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center}</style></head><body><div style="font-size:24px;margin-bottom:16px">Building\u2026</div><div style="width:40px;height:40px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:__sp .8s linear infinite"></div><script>(function(){let r=0;function c(){const p=location.protocol==='https:'?'wss:':'ws:';const ws=new WebSocket(p+'//'+location.host+'${wsPath}');ws.onmessage=function(e){try{const m=JSON.parse(e.data);if((m.type==='version'&&m.hash)||m.type==='error')location.reload();}catch(_){}};ws.onclose=function(){const d=Math.min(500*Math.pow(2,r),8000);r++;setTimeout(c,d);};ws.onerror=function(){ws.close();};}c();})()</script></body></html>`;
12
+ }
13
+
14
+ function withNoStore(res) {
15
+ return res.set("Cache-Control", "no-store");
16
+ }
17
+
18
+ /**
19
+ * @param {Object} opts
20
+ * @param {string} opts.endpoint
21
+ * @param {Object<string, Object>} opts.pageState
22
+ * @param {string} opts.wsPath
23
+ * @param {string} opts.pageTitle
24
+ * @param {string} opts.nodeRoot
25
+ * @param {Function} opts.buildPage
26
+ * @param {Function} opts.buildErrorPage
27
+ * @param {function(Object): Promise<Object|null>} opts.resolveUser Identity provider (lib/providers).
28
+ * @returns {Function}
29
+ */
30
+ function createPortalPageHandler(opts) {
31
+ const {
32
+ endpoint,
33
+ pageState,
34
+ wsPath,
35
+ pageTitle,
36
+ nodeRoot,
37
+ buildPage,
38
+ buildErrorPage,
39
+ resolveUser,
40
+ } = opts;
41
+
42
+ return async function portalPageHandler(req, res) {
43
+ let cssTimer = null;
44
+ try {
45
+ const state = pageState[endpoint];
46
+ if (!state || state.building || !state.compiled) {
47
+ const bWsPath = state?.wsPath || wsPath;
48
+ withNoStore(res)
49
+ .type("text/html")
50
+ .send(buildBuildingPage(bWsPath));
51
+ return;
52
+ }
53
+
54
+ withNoStore(res);
55
+ if (state.compiled.error) {
56
+ if (state.lastGood) {
57
+ const user = state.portalAuth ? await resolveUser(req) : null;
58
+ res
59
+ .type("text/html")
60
+ .send(
61
+ buildPage(
62
+ state.lastGood.pageTitle,
63
+ state.lastGood.compiledJs,
64
+ state.wsPath,
65
+ state.lastGood.customHead,
66
+ state.lastGood.cssHash,
67
+ user,
68
+ state.showWsStatus,
69
+ nodeRoot,
70
+ ),
71
+ );
72
+ return;
73
+ }
74
+
75
+ res
76
+ .status(500)
77
+ .type("text/html")
78
+ .send(
79
+ buildErrorPage(
80
+ state.pageTitle,
81
+ state.compiled.error,
82
+ state.wsPath,
83
+ ),
84
+ );
85
+ return;
86
+ }
87
+
88
+ const { cssHash } = await Promise.race([
89
+ state.cssReady,
90
+ new Promise((_, reject) => {
91
+ cssTimer = setTimeout(
92
+ () => reject(new Error("CSS generation timeout")),
93
+ 15000,
94
+ );
95
+ }),
96
+ ]);
97
+ // Clear the losing timer — without this every request leaves a 15 s
98
+ // timer pending after the CSS promise wins the race.
99
+ clearTimeout(cssTimer);
100
+ const user = state.portalAuth ? await resolveUser(req) : null;
101
+ res
102
+ .type("text/html")
103
+ .send(
104
+ buildPage(
105
+ state.pageTitle,
106
+ state.compiled.js,
107
+ state.wsPath,
108
+ state.customHead,
109
+ cssHash,
110
+ user,
111
+ state.showWsStatus,
112
+ nodeRoot,
113
+ ),
114
+ );
115
+ } catch (e) {
116
+ clearTimeout(cssTimer);
117
+ res
118
+ .status(500)
119
+ .type("text/html")
120
+ .send(
121
+ buildErrorPage(
122
+ pageTitle,
123
+ "Page build failed: " + e.message,
124
+ wsPath,
125
+ ),
126
+ );
127
+ }
128
+ };
129
+ }
130
+
131
+ module.exports = { createPortalPageHandler };
@@ -0,0 +1,536 @@
1
+ /** @module nodes/lib/providers */
2
+
3
+ /**
4
+ * Pluggable providers for @aaqu/fromcubes-react.
5
+ *
6
+ * Two extension points, each a Node-RED plugin type:
7
+ *
8
+ * RED.plugins.registerPlugin("my-sso", {
9
+ * type: "fromcubes-identity",
10
+ * apiVersion: 1,
11
+ * resolveUser(req) { return { userId, login, displayName, email, role, groups } | null },
12
+ * })
13
+ *
14
+ * RED.plugins.registerPlugin("my-css", {
15
+ * type: "fromcubes-css",
16
+ * apiVersion: 1,
17
+ * generate(source) { return { css } },
18
+ * editor: { classes() { return ["p-4", …] } }, // optional: completion
19
+ * })
20
+ *
21
+ * The core builds in only `none` for each kind. Everything that does real
22
+ * work — `headers` (@aaqu/fromcubes-auth-headers) and
23
+ * `tailwind` (@aaqu/fromcubes-tailwind) — is a Node-RED plugin like any third-party one,
24
+ * found through RED.plugins and held to the same apiVersion and validation.
25
+ * Two plugins registering the same provider id of one type are ambiguous and
26
+ * neither is used.
27
+ *
28
+ * Selection is explicit, exactly one provider per kind, first match wins:
29
+ * 1. `RED.settings.portalReact.identityProvider` / `.cssEngine` (settings.js)
30
+ * — the administrator's word; the editor shows it locked.
31
+ * 2. The choice made in the editor (Settings → Fromcubes), persisted in
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.
36
+ * Installing a plugin never switches anything on its own — for identity that
37
+ * would let a package change who counts as logged in.
38
+ *
39
+ * Failure semantics:
40
+ * - identity: unknown provider, wrong apiVersion, throw, timeout or a result
41
+ * that does not validate ⇒ anonymous (`null`). Fail closed: anonymous is the
42
+ * least privileged answer.
43
+ * - css: unknown provider or wrong apiVersion ⇒ `generate` rejects, which the
44
+ * build turns into the yellow `css-fail` status (page still served).
45
+ */
46
+
47
+ const IDENTITY_TYPE = "fromcubes-identity";
48
+ const CSS_TYPE = "fromcubes-css";
49
+ const API_VERSION = 1;
50
+
51
+ const DEFAULT_IDENTITY = "headers";
52
+ const DEFAULT_CSS = "tailwind";
53
+
54
+ // Bounds on what a provider may hand us. Generous enough that anything the
55
+ // built-in header parser produces passes untouched (its groups header is
56
+ // capped at 8 KB, so at most ~4096 one-char groups); anything above is
57
+ // dropped rather than cut, same reasoning as the groups header: a shortened
58
+ // value could match a different, real one.
59
+ const MAX_USER_STRING = 8192;
60
+ const MAX_GROUPS = 4096;
61
+ const IDENTITY_TIMEOUT_MS = 5000;
62
+ // Completion list sent to the editor. Tailwind's own list is a few thousand
63
+ // entries; the caps only stop a plugin from shipping something absurd to
64
+ // every editor that opens a dialog.
65
+ const MAX_EDITOR_CLASSES = 50000;
66
+ const MAX_EDITOR_CLASS_LEN = 200;
67
+
68
+ const USER_STRING_FIELDS = ["login", "displayName", "email", "role"];
69
+
70
+ const KINDS = {
71
+ identity: { type: IDENTITY_TYPE, setting: "identityProvider", fallback: DEFAULT_IDENTITY },
72
+ css: { type: CSS_TYPE, setting: "cssEngine", fallback: DEFAULT_CSS },
73
+ };
74
+
75
+ // Provider ids travel through the settings file, the admin API and the
76
+ // editor's DOM. Plugin ids are free-form in Node-RED; anything outside this
77
+ // shape can be registered but not selected.
78
+ const PROVIDER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
79
+
80
+ /**
81
+ * Error from `select()`, with a code the admin API maps to an HTTP status.
82
+ */
83
+ class ProviderSelectError extends Error {
84
+ /**
85
+ * @param {"bad-kind"|"bad-id"|"locked"|"unknown"} code
86
+ * @param {string} message
87
+ */
88
+ constructor(code, message) {
89
+ super(message);
90
+ this.code = code;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * @param {*} v
96
+ * @returns {boolean}
97
+ */
98
+ function isBoundedString(v) {
99
+ return typeof v === "string" && v.length > 0 && v.length <= MAX_USER_STRING;
100
+ }
101
+
102
+ /**
103
+ * Normalize a provider's answer into a PortalUser, or null. Only the known
104
+ * fields are copied — never the provider's object itself — so a provider
105
+ * cannot smuggle extra keys (or a getter, or a prototype) into `msg._client`
106
+ * and `useNodeRed().user`.
107
+ *
108
+ * @param {*} raw
109
+ * @returns {import("./helpers").PortalUser|null}
110
+ */
111
+ function sanitizeUser(raw) {
112
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
113
+ if (!isBoundedString(raw.userId)) return null;
114
+ const user = { userId: raw.userId };
115
+ for (const field of USER_STRING_FIELDS) {
116
+ if (isBoundedString(raw[field])) user[field] = raw[field];
117
+ }
118
+ const groups = [];
119
+ if (Array.isArray(raw.groups) && raw.groups.length <= MAX_GROUPS) {
120
+ for (const g of raw.groups) if (isBoundedString(g)) groups.push(g);
121
+ }
122
+ user.groups = groups;
123
+ return user;
124
+ }
125
+
126
+ /**
127
+ * The only providers built into the core: one per kind that does nothing, so
128
+ * "no identity" and "no stylesheet" are always choosable. Their ids are
129
+ * reserved.
130
+ *
131
+ * @returns {{ identity: Object<string,Object>, css: Object<string,Object> }}
132
+ */
133
+ function builtinProviders() {
134
+ return {
135
+ identity: {
136
+ none: {
137
+ id: "none",
138
+ type: IDENTITY_TYPE,
139
+ apiVersion: API_VERSION,
140
+ label: "Nobody is identified — every session is anonymous",
141
+ resolveUser: () => null,
142
+ },
143
+ },
144
+ css: {
145
+ none: {
146
+ id: "none",
147
+ type: CSS_TYPE,
148
+ apiVersion: API_VERSION,
149
+ label: "No stylesheet",
150
+ generate: () => ({ css: "" }),
151
+ },
152
+ },
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Create the provider registry bound to `RED`.
158
+ *
159
+ * @param {Object} RED
160
+ * @param {{ identityTimeoutMs?: number, storeFile?: string }} [opts]
161
+ * `storeFile`: where the editor's choice is persisted; without it the
162
+ * choice lives in memory only (tests, embedded use).
163
+ * @returns {{
164
+ * resolveUser: function(Object): Promise<Object|null>,
165
+ * generateCss: function(string): Promise<{css: string}>,
166
+ * identityId: function(): string,
167
+ * cssEngineId: function(): string,
168
+ * describe: function(): Object,
169
+ * select: function(string, string): boolean,
170
+ * editorClasses: function(): Promise<{engine: string, classes: Array<string>}>,
171
+ * }}
172
+ */
173
+ function createProviders(RED, opts = {}) {
174
+ const fs = require("fs");
175
+ const path = require("path");
176
+ const builtins = builtinProviders();
177
+ const tables = { identity: builtins.identity, css: builtins.css };
178
+ const identityTimeoutMs = opts.identityTimeoutMs ?? IDENTITY_TIMEOUT_MS;
179
+ const storeFile = opts.storeFile || null;
180
+ const logged = new Set();
181
+ /** @type {{identity?: string, css?: string}} */
182
+ const chosen = loadChoice();
183
+ // provider object → its validated class list. Keyed by the object, not the
184
+ // id, so a plugin reinstalled at runtime (a new definition) is asked again.
185
+ const classCache = new WeakMap();
186
+
187
+ /**
188
+ * Log a configuration problem once per process — these are evaluated per
189
+ * request, and a misconfigured provider must not flood the log.
190
+ * @param {string} msg
191
+ * @returns {void}
192
+ */
193
+ function logOnce(msg) {
194
+ if (logged.has(msg)) return;
195
+ logged.add(msg);
196
+ RED.log.error("[portal-react] " + msg);
197
+ }
198
+
199
+ /** @returns {Object} */
200
+ function cfg() {
201
+ return (RED.settings && RED.settings.portalReact) || {};
202
+ }
203
+
204
+ /**
205
+ * Read the editor's persisted choice. A missing file is the normal first
206
+ * run; a malformed one is reported and ignored (defaults apply) rather
207
+ * than stopping Node-RED.
208
+ * @returns {{identity?: string, css?: string}}
209
+ */
210
+ function loadChoice() {
211
+ if (!storeFile) return {};
212
+ let raw;
213
+ try {
214
+ raw = fs.readFileSync(storeFile, "utf8");
215
+ } catch (e) {
216
+ if (e.code !== "ENOENT") {
217
+ RED.log.warn(`[portal-react] cannot read ${storeFile}: ${e.message}`);
218
+ }
219
+ return {};
220
+ }
221
+ let data;
222
+ try {
223
+ data = JSON.parse(raw);
224
+ } catch (e) {
225
+ RED.log.warn(`[portal-react] ignoring malformed ${storeFile}: ${e.message}`);
226
+ return {};
227
+ }
228
+ const out = {};
229
+ if (data && typeof data === "object") {
230
+ for (const kind of Object.keys(KINDS)) {
231
+ if (typeof data[kind] === "string" && PROVIDER_ID_RE.test(data[kind])) {
232
+ out[kind] = data[kind];
233
+ }
234
+ }
235
+ }
236
+ return out;
237
+ }
238
+
239
+ /**
240
+ * Persist the choice atomically: a crash mid-write must not leave a
241
+ * half-written file that silently resets identity to the default.
242
+ * @returns {void}
243
+ */
244
+ function saveChoice() {
245
+ if (!storeFile) return;
246
+ fs.mkdirSync(path.dirname(storeFile), { recursive: true });
247
+ const tmp = storeFile + ".tmp";
248
+ fs.writeFileSync(tmp, JSON.stringify(chosen, null, 2) + "\n", "utf8");
249
+ fs.renameSync(tmp, storeFile);
250
+ }
251
+
252
+ /**
253
+ * The provider id in force for `kind`, and where it came from.
254
+ * @param {"identity"|"css"} kind
255
+ * @returns {{ id: string, source: "settings"|"editor"|"default" }}
256
+ */
257
+ function current(kind) {
258
+ const k = KINDS[kind];
259
+ const fromSettings = cfg()[k.setting];
260
+ if (typeof fromSettings === "string" && fromSettings) {
261
+ return { id: fromSettings, source: "settings" };
262
+ }
263
+ if (chosen[kind]) return { id: chosen[kind], source: "editor" };
264
+ return { id: k.fallback, source: "default" };
265
+ }
266
+
267
+ /**
268
+ * Plugins registered with Node-RED for `type`. Never throws.
269
+ * @param {string} type
270
+ * @returns {Array<Object>}
271
+ */
272
+ function pluginsOf(type) {
273
+ try {
274
+ return ((RED.plugins && RED.plugins.getByType(type)) || []).filter(Boolean);
275
+ } catch (_) {
276
+ return [];
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Find the provider `id` of `type`: built-ins first (their ids are
282
+ * reserved), then plugins registered with Node-RED. An id registered by
283
+ * more than one plugin is refused rather than resolved by load order — for
284
+ * identity, "whichever package loaded first" must never decide who is
285
+ * logged in.
286
+ *
287
+ * @param {string} type
288
+ * @param {Object<string,Object>} table
289
+ * @param {string} id
290
+ * @returns {{ provider: Object|null, error: string|null }}
291
+ */
292
+ function lookup(type, table, id) {
293
+ const plugins = pluginsOf(type);
294
+ // Scanned before the built-in short-circuit: a plugin shadowing a
295
+ // built-in id must be reported even while the built-in is in use.
296
+ for (const p of plugins) {
297
+ if (p && Object.prototype.hasOwnProperty.call(table, p.id)) {
298
+ logOnce(`${type} plugin "${p.id}" ignored: id is reserved for a built-in`);
299
+ }
300
+ }
301
+ if (Object.prototype.hasOwnProperty.call(table, id)) {
302
+ return { provider: table[id], error: null };
303
+ }
304
+ const matches = plugins.filter((p) => p && p.id === id);
305
+ if (matches.length === 0) {
306
+ return { provider: null, error: `${type} provider "${id}" is not registered` };
307
+ }
308
+ if (matches.length > 1) {
309
+ const mods = matches.map((p) => p.module || "?").join(", ");
310
+ return {
311
+ provider: null,
312
+ error: `${type} provider "${id}" is registered by more than one package (${mods})`,
313
+ };
314
+ }
315
+ const found = matches[0];
316
+ if (found.apiVersion !== API_VERSION) {
317
+ return {
318
+ provider: null,
319
+ error: `${type} provider "${id}" has apiVersion ${JSON.stringify(found.apiVersion)}, expected ${API_VERSION}`,
320
+ };
321
+ }
322
+ return { provider: found, error: null };
323
+ }
324
+
325
+ /** @returns {string} */
326
+ function identityId() {
327
+ return current("identity").id;
328
+ }
329
+
330
+ /** @returns {string} */
331
+ function cssEngineId() {
332
+ return current("css").id;
333
+ }
334
+
335
+ /**
336
+ * Everything the editor's settings pane needs: per kind, what is in force,
337
+ * where it came from, whether it can be changed, and every provider that
338
+ * could be chosen. A selected id that is no longer registered stays in
339
+ * the list, flagged `missing`, so the editor can say so instead of
340
+ * silently showing nothing selected.
341
+ *
342
+ * @returns {Object<string, {selected: string, source: string, locked: boolean, missing: boolean,
343
+ * options: Array<{id: string, label: string, module: string, builtin: boolean, missing?: boolean, problem?: string}>}>}
344
+ */
345
+ function describe() {
346
+ const out = {};
347
+ for (const [kind, k] of Object.entries(KINDS)) {
348
+ const table = tables[kind];
349
+ const options = Object.values(table).map((p) => ({
350
+ id: p.id,
351
+ label: p.label || "",
352
+ module: "built-in",
353
+ builtin: true,
354
+ }));
355
+ const seen = new Set(options.map((o) => o.id));
356
+ const plugins = pluginsOf(k.type);
357
+ const count = {};
358
+ for (const p of plugins) count[p.id] = (count[p.id] || 0) + 1;
359
+ for (const p of plugins) {
360
+ if (typeof p.id !== "string" || seen.has(p.id)) continue;
361
+ seen.add(p.id);
362
+ const opt = {
363
+ id: p.id,
364
+ label: typeof p.label === "string" ? p.label : "",
365
+ module: typeof p.module === "string" ? p.module : "",
366
+ builtin: false,
367
+ };
368
+ if (count[p.id] > 1) {
369
+ opt.problem = "registered by more than one package — neither is used";
370
+ } else if (p.apiVersion !== API_VERSION) {
371
+ opt.problem = `apiVersion ${JSON.stringify(p.apiVersion)}, expected ${API_VERSION}`;
372
+ } else if (!PROVIDER_ID_RE.test(p.id)) {
373
+ opt.problem = "id cannot be selected (letters, digits, . _ - only)";
374
+ }
375
+ options.push(opt);
376
+ }
377
+ const { id, source } = current(kind);
378
+ const missing = !seen.has(id);
379
+ if (missing) {
380
+ options.push({ id, label: "", module: "", builtin: false, missing: true });
381
+ }
382
+ out[kind] = { selected: id, source, locked: source === "settings", missing, options };
383
+ }
384
+ return out;
385
+ }
386
+
387
+ /**
388
+ * Choose the provider for `kind` from the editor and persist it. Only a
389
+ * provider that is registered and usable right now can be chosen.
390
+ *
391
+ * @param {string} kind "identity" | "css"
392
+ * @param {string} id
393
+ * @returns {boolean} true when the provider in force changed.
394
+ * @throws {ProviderSelectError}
395
+ */
396
+ function select(kind, id) {
397
+ if (!Object.prototype.hasOwnProperty.call(KINDS, kind)) {
398
+ throw new ProviderSelectError("bad-kind", `unknown provider kind "${kind}"`);
399
+ }
400
+ if (typeof id !== "string" || !PROVIDER_ID_RE.test(id)) {
401
+ throw new ProviderSelectError("bad-id", "invalid provider id");
402
+ }
403
+ const before = current(kind);
404
+ if (before.source === "settings") {
405
+ throw new ProviderSelectError(
406
+ "locked",
407
+ `${kind} provider is set in settings.js (portalReact.${KINDS[kind].setting})`,
408
+ );
409
+ }
410
+ const { provider, error } = lookup(KINDS[kind].type, tables[kind], id);
411
+ if (!provider) throw new ProviderSelectError("unknown", error);
412
+ const prev = chosen[kind];
413
+ chosen[kind] = id;
414
+ try {
415
+ saveChoice();
416
+ } catch (e) {
417
+ if (prev === undefined) delete chosen[kind];
418
+ else chosen[kind] = prev;
419
+ throw e;
420
+ }
421
+ return before.id !== id;
422
+ }
423
+
424
+ /**
425
+ * Who is behind this request. Never rejects: every failure is anonymous.
426
+ *
427
+ * @param {Object} req HTTP request or WS upgrade request (Node IncomingMessage).
428
+ * @returns {Promise<Object|null>}
429
+ */
430
+ async function resolveUser(req) {
431
+ const id = identityId();
432
+ const { provider, error } = lookup(IDENTITY_TYPE, builtins.identity, id);
433
+ if (!provider) {
434
+ logOnce(error + " — every session is anonymous");
435
+ return null;
436
+ }
437
+ if (typeof provider.resolveUser !== "function") {
438
+ logOnce(`${IDENTITY_TYPE} provider "${id}" has no resolveUser() — every session is anonymous`);
439
+ return null;
440
+ }
441
+ let timer = null;
442
+ try {
443
+ const raw = await Promise.race([
444
+ Promise.resolve().then(() => provider.resolveUser(req)),
445
+ new Promise((_, reject) => {
446
+ timer = setTimeout(
447
+ () => reject(new Error(`timed out after ${identityTimeoutMs} ms`)),
448
+ identityTimeoutMs,
449
+ );
450
+ }),
451
+ ]);
452
+ return sanitizeUser(raw);
453
+ } catch (e) {
454
+ RED.log.warn(`[portal-react] identity provider "${id}" failed: ${e.message}`);
455
+ return null;
456
+ } finally {
457
+ clearTimeout(timer);
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Generate the stylesheet for a portal's source. Rejects on failure — the
463
+ * caller owns the `css-fail` status.
464
+ *
465
+ * @param {string} source
466
+ * @returns {Promise<{css: string}>}
467
+ */
468
+ async function generateCss(source) {
469
+ const id = cssEngineId();
470
+ const { provider, error } = lookup(CSS_TYPE, builtins.css, id);
471
+ if (!provider) throw new Error(error);
472
+ if (typeof provider.generate !== "function") {
473
+ throw new Error(`${CSS_TYPE} provider "${id}" has no generate()`);
474
+ }
475
+ const out = await provider.generate(source);
476
+ if (!out || typeof out.css !== "string") {
477
+ throw new Error(`${CSS_TYPE} provider "${id}" returned no css string`);
478
+ }
479
+ return { css: out.css };
480
+ }
481
+
482
+ /**
483
+ * Class names the editor offers inside `className="…"` for the engine in
484
+ * force. Optional for plugins (`editor.classes()`); an engine without it,
485
+ * an unusable engine, or one that fails yields an empty list — completion
486
+ * just stays quiet, nothing else depends on it.
487
+ *
488
+ * @returns {Promise<{engine: string, classes: Array<string>}>}
489
+ */
490
+ async function editorClasses() {
491
+ const engine = cssEngineId();
492
+ const { provider } = lookup(CSS_TYPE, builtins.css, engine);
493
+ const fn = provider && provider.editor && provider.editor.classes;
494
+ if (typeof fn !== "function") return { engine, classes: [] };
495
+ if (classCache.has(provider)) return { engine, classes: classCache.get(provider) };
496
+ let raw;
497
+ try {
498
+ raw = await fn.call(provider.editor);
499
+ } catch (e) {
500
+ RED.log.warn(`[portal-react] css engine "${engine}" editor.classes() failed: ${e.message}`);
501
+ return { engine, classes: [] };
502
+ }
503
+ const classes = [];
504
+ if (Array.isArray(raw)) {
505
+ const seen = new Set();
506
+ for (const c of raw) {
507
+ if (classes.length >= MAX_EDITOR_CLASSES) break;
508
+ if (typeof c !== "string" || !c || c.length > MAX_EDITOR_CLASS_LEN || /\s/.test(c)) continue;
509
+ if (seen.has(c)) continue;
510
+ seen.add(c);
511
+ classes.push(c);
512
+ }
513
+ } else {
514
+ RED.log.warn(`[portal-react] css engine "${engine}" editor.classes() did not return an array`);
515
+ }
516
+ classCache.set(provider, classes);
517
+ return { engine, classes };
518
+ }
519
+
520
+ return { resolveUser, generateCss, identityId, cssEngineId, describe, select, editorClasses };
521
+ }
522
+
523
+ module.exports = {
524
+ createProviders,
525
+ sanitizeUser,
526
+ ProviderSelectError,
527
+ PROVIDER_ID_RE,
528
+ IDENTITY_TYPE,
529
+ CSS_TYPE,
530
+ API_VERSION,
531
+ DEFAULT_IDENTITY,
532
+ DEFAULT_CSS,
533
+ MAX_USER_STRING,
534
+ MAX_GROUPS,
535
+ MAX_EDITOR_CLASSES,
536
+ };