UNPKG

@uns-kit/core

Version:

Core utilities and runtime building blocks for UNS-based realtime transformers.

443 lines 18.1 kB
import grpc from "@grpc/grpc-js"; import protoLoader from "@grpc/proto-loader"; import path from "path"; import getPort from "get-port"; import { readFileSync } from "fs"; import { fileURLToPath } from "url"; import { basePath } from "../base-path.js"; import logger from "../logger.js"; import { ConfigFile } from "../config-file.js"; import UnsProxyProcess from "../uns/uns-proxy-process.js"; import { MessageMode } from "../uns-mqtt/uns-mqtt-proxy.js"; import { UnsPacket } from "../uns/uns-packet.js"; import { randomUUID } from "crypto"; import { MqttTopicBuilder } from "../uns-mqtt/mqtt-topic-builder.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const defaultGatewayProto = path.resolve(__dirname, "uns-gateway.proto"); const GATEWAY_PROTO = process.env.UNS_GATEWAY_PROTO ? path.resolve(process.cwd(), process.env.UNS_GATEWAY_PROTO) : defaultGatewayProto; const requireHost = (value, pathLabel) => { if (typeof value !== "string" || value.length === 0) { throw new Error(`Configuration value '${pathLabel}' is required and must resolve to a string host.`); } return value; }; export class UnsGatewayServer { server = null; unsProcess = null; mqttInput = null; mqttOutput = null; handlers = new Set(); unsApiProxy = null; apiStreams = new Set(); pendingApi = new Map(); inputHost; outputHost; apiOptions = null; outPublisherActive = false; inSubscriberActive = false; async start(desiredAddr, opts) { const packageDef = protoLoader.loadSync(GATEWAY_PROTO, { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true, }); const proto = grpc.loadPackageDefinition(packageDef); // Load config and init UNS process + MQTT proxies const cfg = await ConfigFile.loadConfig(); const processName = opts?.processNameOverride ?? cfg.uns.processName; const instanceMode = opts?.instanceModeOverride ?? cfg.uns.instanceMode; const handover = (typeof opts?.handoverOverride === "boolean") ? opts.handoverOverride : cfg.uns.handover; const suffix = opts?.instanceSuffix ? `-${opts.instanceSuffix}` : ""; const infraHost = requireHost(cfg.infra?.host, "infra.host"); this.unsProcess = new UnsProxyProcess(infraHost, { processName }); // cache hosts/options; proxies created lazily on first use this.inputHost = requireHost(cfg.input?.host, "input.host"); this.outputHost = requireHost(cfg.output?.host, "output.host"); this.apiOptions = cfg.uns?.jwksWellKnownUrl ? { jwks: { wellKnownJwksUrl: cfg.uns.jwksWellKnownUrl, activeKidUrl: cfg.uns.kidWellKnownUrl } } : { jwtSecret: "CHANGEME" }; const serviceImpl = { Publish: this.publish.bind(this), Subscribe: this.subscribe.bind(this), RegisterApiGet: this.registerApiGet.bind(this), UnregisterApiGet: this.unregisterApiGet.bind(this), ApiEventStream: this.apiEventStream.bind(this), Ready: this.ready.bind(this), }; this.server = new grpc.Server(); this.server.addService(proto.uns.UnsGateway.service, serviceImpl); const isUnix = process.platform !== "win32"; let addr = desiredAddr || process.env.UNS_GATEWAY_ADDR || null; if (!addr) { if (isUnix) { const sanitizedProcess = MqttTopicBuilder.sanitizeTopicPart(this.getProcessName()); const sock = `/tmp/${sanitizedProcess}-uns-gateway.sock`; addr = `unix:${sock}`; } else { const port = await getPort(); addr = `0.0.0.0:${port}`; } } // If UDS and file exists, best-effort unlink (stale sock) if (addr.startsWith("unix:")) { const fs = await import("fs"); const p = addr.slice("unix:".length); try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { } } await new Promise((resolve, reject) => { this.server.bindAsync(addr, grpc.ServerCredentials.createInsecure(), (err) => { if (err) return reject(err); // grpc-js automatically starts the server after bindAsync in recent versions // Calling start() is deprecated; omit to avoid warnings. logger.info(`UNS gRPC Gateway listening on ${addr}`); resolve(); }); }); return { address: addr, isUDS: addr.startsWith("unix:") }; } getProcessName() { try { const pkgPath = path.join(basePath, "package.json"); const raw = readFileSync(pkgPath, "utf8"); const pkg = JSON.parse(raw); return `${pkg.name}-${pkg.version}`; } catch { return `uns-gateway`; } } async publish(call, callback) { try { await this.ensureMqttOutput(); const req = call.request; if (!this.mqttOutput) throw new Error("Gateway not initialized"); const topic = req.topic; const attribute = req.attribute; const description = req.description ?? ""; const tags = (req.tags ?? []); const attributeNeedsPersistence = req.attribute_needs_persistence ?? null; const valueIsCumulative = req.value_is_cumulative ?? false; let message = null; if (req.data) { const d = req.data; const time = d.time; const uom = d.uom || undefined; const dataGroup = d.data_group || undefined; const foreignEventKey = d.foreign_event_key || undefined; let value = undefined; if (typeof d.value_number === "number" && !Number.isNaN(d.value_number)) { value = d.value_number; } else if (typeof d.value_string === "string" && d.value_string.length > 0) { value = d.value_string; } if (value === undefined) throw new Error("Data.value_number or Data.value_string must be set"); message = { data: { time, value, uom, dataGroup, foreignEventKey } }; } else if (req.table) { const t = req.table; const time = t.time; const dataGroup = t.data_group || undefined; const values = {}; (t.values ?? []).forEach((kv) => { const key = kv.key; if (typeof kv.value_number === "number" && !Number.isNaN(kv.value_number)) { values[key] = kv.value_number; } else if (typeof kv.value_string === "string") { values[key] = kv.value_string; } else { values[key] = null; } }); message = { table: { time, values, dataGroup } }; } else { throw new Error("PublishRequest.content must be data or table"); } const packet = await UnsPacket.unsPacketFromUnsMessage(message); const mqttMsg = { topic, attribute, description, tags, packet, attributeNeedsPersistence, }; // delta mode if cumulative if (message.data && valueIsCumulative) { this.mqttOutput.publishMqttMessage(mqttMsg, MessageMode.Delta); } else { this.mqttOutput.publishMqttMessage(mqttMsg, MessageMode.Raw); } callback(null, { ok: true }); } catch (err) { logger.error(`Gateway Publish error: ${err.message}`); callback(null, { ok: false, error: err.message }); } } async subscribe(call) { await this.ensureMqttInput(); const req = call.request; const topics = (req.topics ?? []); if (topics.length === 0) { call.emit("error", { code: grpc.status.INVALID_ARGUMENT, details: "topics is required" }); call.end(); return; } // Subscribe and stream messages to this client this.mqttInput.subscribeAsync(topics); const handler = (event) => { try { // Forward as UNS packet JSON if parsable, else raw message const payload = event.packet ? JSON.stringify(event.packet) : String(event.message ?? ""); call.write({ topic: event.topic, payload }); } catch (e) { // drop } }; this.handlers.add(handler); this.mqttInput.event.on("input", handler); call.on("cancelled", () => this.cleanupHandler(handler)); call.on("error", () => this.cleanupHandler(handler)); call.on("close", () => this.cleanupHandler(handler)); } attachStatusListeners() { if (this.mqttOutput) { this.mqttOutput.event.on("mqttProxyStatus", (e) => { if (e?.event === "t-publisher-active") this.outPublisherActive = !!e.value; }); } if (this.mqttInput) { this.mqttInput.event.on("mqttProxyStatus", (e) => { if (e?.event === "t-subscriber-active") this.inSubscriberActive = !!e.value; }); } } async ensureMqttOutput() { if (!this.mqttOutput) { // slight delay to let process MQTT connect while (this.unsProcess?.processMqttProxy?.isConnected === false) { await new Promise((r) => setTimeout(r, 50)); } this.mqttOutput = await this.unsProcess.createUnsMqttProxy(this.outputHost, this.getInstanceName("gatewayOutput"), "force", true, { publishThrottlingDelay: 1 }); this.attachStatusListeners(); } } async ensureMqttInput() { if (!this.mqttInput) { while (this.unsProcess?.processMqttProxy?.isConnected === false) { await new Promise((r) => setTimeout(r, 50)); } this.mqttInput = await this.unsProcess.createUnsMqttProxy(this.inputHost, this.getInstanceName("gatewayInput"), "force", true, { mqttSubToTopics: [] }); this.attachStatusListeners(); } } async ensureApiProxy() { if (!this.unsApiProxy) { if (typeof this.unsProcess?.createApiProxy !== "function") { throw new Error("API plugin not registered. Please install @uns-kit/api and register it with UnsProxyProcess before starting the gateway."); } this.unsApiProxy = await this.unsProcess.createApiProxy(this.getInstanceName("gatewayApi"), this.apiOptions); this.unsApiProxy.event.on("apiGetEvent", (event) => this.onApiGetEvent(event)); } } getInstanceName(base) { // derive suffix from processName/CLI by inspecting configured instanceStatusTopic is overkill; keep base names unique per process return base; } async registerApiGet(call, callback) { try { await this.ensureApiProxy(); const req = call.request; const topic = req.topic; const attribute = req.attribute; const apiDescription = req.api_description || undefined; const tags = (req.tags ?? []); const queryParams = (req.query_params ?? []).map((p) => ({ name: p.name, type: (p.type === "number" || p.type === "boolean") ? p.type : "string", required: !!p.required, description: p.description ?? undefined, })); const options = { apiDescription, tags, queryParams, }; await this.unsApiProxy.get(topic, attribute, options); callback(null, { ok: true }); } catch (err) { logger.error(`Gateway RegisterApiGet error: ${err.message}`); callback(null, { ok: false, error: err.message }); } } async unregisterApiGet(call, callback) { try { await this.ensureApiProxy(); const req = call.request; const topic = req.topic; const attribute = req.attribute; await this.unsApiProxy.unregister(topic, attribute, "GET"); callback(null, { ok: true }); } catch (err) { logger.error(`Gateway UnregisterApiGet error: ${err.message}`); callback(null, { ok: false, error: err.message }); } } onApiGetEvent(event) { // Correlate request and forward to connected gRPC streams const id = randomUUID(); const req = event.req; const res = event.res; const path = req.path || req.originalUrl || "/"; // Derive topic/attribute is optional; we send path and query const bearer = req.headers?.["authorization"] ?? ""; this.pendingApi.set(id, res); // Timeout after 10s setTimeout(() => { if (this.pendingApi.has(id)) { const r = this.pendingApi.get(id); try { r.status(504).send("Gateway timeout"); } catch { } this.pendingApi.delete(id); } }, 10_000).unref?.(); const query = {}; Object.entries(req.query || {}).forEach(([k, v]) => { query[k] = String(v); }); const msg = { id, method: "GET", path, query, bearer }; for (const stream of this.apiStreams) { try { stream.write(msg); } catch { } } } async apiEventStream(call) { // Register stream await this.ensureApiProxy(); this.apiStreams.add(call); call.on("data", (resp) => { const id = resp.id; const status = resp.status ?? 200; const body = resp.body ?? ""; const headers = resp.headers ?? {}; const res = this.pendingApi.get(id); if (res) { try { Object.entries(headers).forEach(([k, v]) => res.setHeader(k, v)); res.status(status).send(body); } catch { } this.pendingApi.delete(id); } }); const cleanup = () => { this.apiStreams.delete(call); }; call.on("cancelled", cleanup); call.on("error", cleanup); call.on("close", cleanup); } async ready(call, callback) { try { const req = call.request; const timeoutMs = req.timeout_ms && req.timeout_ms > 0 ? req.timeout_ms : 15000; const waitOut = !!req.wait_output; const waitIn = !!req.wait_input; const waitApi = !!req.wait_api; if (waitOut) await this.ensureMqttOutput(); if (waitIn) await this.ensureMqttInput(); if (waitApi) await this.ensureApiProxy(); const start = Date.now(); const check = () => { const okOut = !waitOut || this.outPublisherActive; const okIn = !waitIn || this.inSubscriberActive; const okApi = !waitApi || !!this.unsApiProxy; // creation ensures listening return okOut && okIn && okApi; }; if (check()) return callback(null, { ok: true }); const onStatus = () => { if (check()) done(true); }; const done = (ok, err) => { if (this.mqttOutput) this.mqttOutput.event.off("mqttProxyStatus", onStatus); if (this.mqttInput) this.mqttInput.event.off("mqttProxyStatus", onStatus); callback(null, { ok, error: err }); }; if (this.mqttOutput) this.mqttOutput.event.on("mqttProxyStatus", onStatus); if (this.mqttInput) this.mqttInput.event.on("mqttProxyStatus", onStatus); const iv = setInterval(() => { if (check()) { clearInterval(iv); done(true); } else if (Date.now() - start > timeoutMs) { clearInterval(iv); done(false, "timeout waiting for readiness"); } }, 100); } catch (e) { callback(null, { ok: false, error: e.message }); } } cleanupHandler(handler) { if (this.mqttInput) this.mqttInput.event.off("input", handler); this.handlers.delete(handler); } async shutdown() { try { for (const h of Array.from(this.handlers)) this.cleanupHandler(h); if (this.server) { await new Promise((resolve) => this.server.tryShutdown(() => resolve())); this.server = null; } if (this.unsProcess) this.unsProcess.shutdown(); } catch (e) { logger.error(`Gateway shutdown error: ${e.message}`); } } } export async function startUnsGateway(addrOverride, opts) { const gw = new UnsGatewayServer(); return gw.start(addrOverride, opts); } function sanitizeTopicPart(getProcessName) { throw new Error("Function not implemented."); } //# sourceMappingURL=uns-gateway-server.js.map