Node-RED's one broker node pointed at zurag.be:1884, where nothing listens. nodered now requires
mqtt-topic (asking for every topic: flows follow the devices' own) and a run-once `mqtt` step —
declared last, restarted when the binding, credential or settings change — points the mesh's broker
nodes at the bound broker through Node-RED's admin API with the module's api-token: the node the
step makes itself when none is named, or the ones an assignment names in `mqtt.brokers`. Only host,
port, TLS and the login change; the broker is asked first whether it takes the login; the deploy is
against the revision read ("nodes", so only that node restarts) and a digest makes a rerun a no-op.
A broker node nobody named is never touched. settings.js keeps `mqtt` and `topics` out of Node-RED.
132 lines
5.4 KiB
TypeScript
132 lines
5.4 KiB
TypeScript
// Node-RED's admin-API client — nodered's own code, living in the module (novox/hq ADR 0039). Only
|
|
// this module's tools import it; nodered has nothing to poll, so there is no events entrypoint.
|
|
//
|
|
// Node-RED exposes a runtime admin API under its base URL: GET/POST /flows for the whole flow
|
|
// configuration, GET /nodes for installed node modules. A default install has no auth; when
|
|
// adminAuth is on, a bearer token is required — the module's settings accept the mesh-minted
|
|
// api-token, which the runtime config file carries as `token`.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
export interface NodeRedFlow {
|
|
/** The tab (flow) node id. */
|
|
id: string;
|
|
label: string;
|
|
disabled: boolean;
|
|
}
|
|
|
|
export interface NodeRedNodeModule {
|
|
name: string;
|
|
version: string;
|
|
types: string[];
|
|
}
|
|
|
|
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
|
|
function meshConfig(file?: string): Record<string, string> {
|
|
if (!file) return {};
|
|
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
|
catch { return {}; }
|
|
}
|
|
|
|
export class NodeRedClient {
|
|
readonly baseUrl: string;
|
|
|
|
constructor(
|
|
url: string,
|
|
private readonly token?: string,
|
|
) {
|
|
this.baseUrl = url.replace(/\/$/, "");
|
|
}
|
|
|
|
/**
|
|
* Build from the module's resolved environment. MESH_NODERED_URL locates the admin API and is the
|
|
* "this node runs Node-RED" signal — throws when unset, and the module then contributes nothing
|
|
* rather than failing on every node. MESH_NODERED_TOKEN is the bearer token when adminAuth is on;
|
|
* a default install needs none.
|
|
*/
|
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): NodeRedClient {
|
|
const cfg = meshConfig(env.MESH_NODERED_CONFIG_FILE);
|
|
const url = cfg.url ?? env.MESH_NODERED_URL;
|
|
if (!url) throw new Error("no Node-RED URL — set MESH_NODERED_URL");
|
|
return new NodeRedClient(url, cfg.token ?? env.MESH_NODERED_TOKEN);
|
|
}
|
|
|
|
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
|
return { Accept: "application/json", ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), ...extra };
|
|
}
|
|
|
|
private async req(path: string, init: RequestInit = {}): Promise<any> {
|
|
const res = await fetch(`${this.baseUrl}${path}`, init);
|
|
if (!res.ok) throw new Error(`Node-RED ${path}: ${res.status} ${await res.text()}`);
|
|
return res.json();
|
|
}
|
|
|
|
/** The full flow configuration — the flat array of every node across every tab. */
|
|
async getConfig(): Promise<any[]> {
|
|
const body = await this.req("/flows", { headers: this.headers() });
|
|
// /flows answers a bare array by default, or { rev, flows } to a v2-aware client.
|
|
return Array.isArray(body) ? body : (body.flows ?? []);
|
|
}
|
|
|
|
/** The tabs (flows), each a node of type "tab" in the configuration. */
|
|
async listFlows(): Promise<{ flows: NodeRedFlow[]; nodeCount: number }> {
|
|
const config = await this.getConfig();
|
|
const flows = config
|
|
.filter((n) => n.type === "tab")
|
|
.map((n) => ({ id: n.id, label: n.label ?? "(unnamed)", disabled: !!n.disabled }));
|
|
return { flows, nodeCount: config.length };
|
|
}
|
|
|
|
async listNodes(): Promise<NodeRedNodeModule[]> {
|
|
const modules = (await this.req("/nodes", { headers: this.headers() })) as any[];
|
|
return modules.map((m) => ({ name: m.name, version: m.version, types: m.types ?? [] }));
|
|
}
|
|
|
|
/** The whole flow configuration with its revision (API v2), for a deploy that must not clobber
|
|
* a change made meanwhile. */
|
|
async flowsWithRev(): Promise<{ rev: string; flows: any[] }> {
|
|
const body = await this.req("/flows", { headers: this.headers({ "Node-RED-API-Version": "v2" }) });
|
|
return { rev: String(body?.rev ?? ""), flows: Array.isArray(body?.flows) ? body.flows : [] };
|
|
}
|
|
|
|
/** A node's stored credentials as Node-RED shows them: plain fields, and `has_<field>` for secret ones. */
|
|
async credentials(type: string, id: string): Promise<{ user?: string; has_password?: boolean }> {
|
|
return (await this.req(`/credentials/${encodeURIComponent(type)}/${encodeURIComponent(id)}`, { headers: this.headers() })) ?? {};
|
|
}
|
|
|
|
/**
|
|
* Deploy the flow configuration read at `rev`. Node-RED answers 409 when the flows changed since,
|
|
* rather than overwriting what someone deployed in between. A node carrying `credentials` has them
|
|
* stored (encrypted) and counts as changed, so a "nodes" deploy restarts it and nothing else.
|
|
*/
|
|
async deployFlowsAt(rev: string, config: any[], type = "nodes"): Promise<void> {
|
|
await this.req("/flows", {
|
|
method: "POST",
|
|
headers: this.headers({
|
|
"Content-Type": "application/json",
|
|
"Node-RED-API-Version": "v2",
|
|
"Node-RED-Deployment-Type": type,
|
|
}),
|
|
body: JSON.stringify({ rev, flows: config }),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Replace the whole flow configuration and deploy. Returns the new revision. `type` maps to
|
|
* Node-RED's deployment types — "full" (default), "nodes", or "flows".
|
|
*/
|
|
async deployFlows(config: any[], type = "full"): Promise<{ rev?: string; nodeCount: number }> {
|
|
const body = await this.req("/flows", {
|
|
method: "POST",
|
|
// v2 answers { rev }; v1 answers 204 with no body, which req() cannot parse.
|
|
headers: this.headers({
|
|
"Content-Type": "application/json",
|
|
"Node-RED-API-Version": "v2",
|
|
"Node-RED-Deployment-Type": type,
|
|
}),
|
|
body: JSON.stringify({ flows: config }),
|
|
});
|
|
return { rev: body?.rev, nodeCount: config.length };
|
|
}
|
|
}
|