feat: establish standalone Device Core repository

This commit is contained in:
DCCONSTRUCTIONS
2026-08-21 11:51:21 +03:00
commit e0bac205d0
244 changed files with 51962 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
import {
normalizeAdapterAcceptance,
} from "../../../packages/device-protocol-contract/src/index.mjs";
export function createCoreGatewayClient(options = {}) {
const {
coreUrl,
gatewayToken,
timeoutMs = 5000,
fetchImpl = fetch,
} = options;
const baseUrl = normalizeCoreBaseUrl(coreUrl);
validateClientOptions({ gatewayToken, timeoutMs, fetchImpl });
const discoveryEndpoint = endpointUrl(
baseUrl,
"/internal/v1/device-discoveries:observe",
);
const messageEndpoint = endpointUrl(
baseUrl,
"/internal/v1/gateway/messages:accept",
);
return Object.freeze({
async observeDiscovery(signal) {
const body = await postJson({
endpoint: discoveryEndpoint,
gatewayToken,
timeoutMs,
fetchImpl,
value: signal,
maxResponseBytes: 32 * 1024,
});
if (
!body.discovery
|| !["quarantine", "claimed"].includes(body.discovery.lifecycleState)
|| body.discovery.commandTransport !== "disabled"
) {
throw new Error("device_gateway_core_ingest_contract_invalid");
}
return body.discovery;
},
async acceptMessage(message) {
const body = await postJson({
endpoint: messageEndpoint,
gatewayToken,
timeoutMs,
fetchImpl,
value: message,
maxResponseBytes: 16 * 1024,
});
const acceptance = normalizeAdapterAcceptance(body.acceptance);
if (acceptance.idempotencyKey !== message?.idempotencyKey) {
throw new Error("device_gateway_core_acceptance_mismatch");
}
return acceptance;
},
});
}
export function createCoreDiscoveryClient({
coreUrl,
gatewayToken,
timeoutMs = 5000,
fetchImpl = fetch,
} = {}) {
return createCoreGatewayClient({
coreUrl,
gatewayToken,
timeoutMs,
fetchImpl,
}).observeDiscovery;
}
function validateClientOptions({ gatewayToken, timeoutMs, fetchImpl }) {
if (typeof gatewayToken !== "string" || gatewayToken.length < 32) {
throw new TypeError("device_gateway_core_token_invalid");
}
const normalizedTimeout = Number(timeoutMs);
if (
!Number.isSafeInteger(normalizedTimeout)
|| normalizedTimeout < 100
|| normalizedTimeout > 30_000
) {
throw new TypeError("device_gateway_core_timeout_invalid");
}
if (typeof fetchImpl !== "function") {
throw new TypeError("device_gateway_core_fetch_invalid");
}
}
async function postJson({
endpoint,
gatewayToken,
timeoutMs,
fetchImpl,
value,
maxResponseBytes,
}) {
const response = await fetchImpl(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${gatewayToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(value),
signal: AbortSignal.timeout(Number(timeoutMs)),
});
const body = await readBoundedJson(response, maxResponseBytes);
if (!response.ok || body?.ok !== true) {
throw new Error("device_gateway_core_ingest_failed");
}
return body;
}
function normalizeCoreBaseUrl(value) {
let url;
try {
url = new URL(String(value || ""));
} catch {
throw new TypeError("device_gateway_core_url_invalid");
}
if (url.protocol !== "http:" || url.username || url.password) {
throw new TypeError("device_gateway_core_url_invalid");
}
if (url.pathname !== "/" || url.search || url.hash) {
throw new TypeError("device_gateway_core_url_invalid");
}
return url;
}
function endpointUrl(baseUrl, pathname) {
const url = new URL(baseUrl);
url.pathname = pathname;
return url.toString();
}
async function readBoundedJson(response, maxBytes) {
const text = await response.text();
if (Buffer.byteLength(text, "utf8") > maxBytes) {
throw new Error("device_gateway_core_response_too_large");
}
try {
return JSON.parse(text);
} catch {
throw new Error("device_gateway_core_response_invalid");
}
}
+646
View File
@@ -0,0 +1,646 @@
import { createHash, randomUUID } from "node:crypto";
import { createServer as createHttpServer } from "node:http";
import { createServer as createTcpServer } from "node:net";
import {
assertDeviceAdapterSession,
} from "../../../packages/device-adapter-runtime/src/index.mjs";
import {
DEVICE_ADAPTER_MESSAGE_SCHEMA,
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
normalizeAdapterAcceptance,
normalizeAdapterMessage,
} from "../../../packages/device-protocol-contract/src/index.mjs";
export function createDeviceGatewayRuntime(options = {}) {
const config = normalizeConfig(options);
const sessions = new Map();
const sessionsByAddress = new Map();
const connectionWindows = new Map();
let totalAccepted = 0;
let totalRejected = 0;
let totalDiscoveries = 0;
let totalMessagesAccepted = 0;
let totalPackagesAcknowledged = 0;
let totalBufferedBytes = 0;
const tcpServer = createTcpServer((socket) => {
const remoteAddress = normalizeRemoteAddress(socket.remoteAddress);
if (
sessions.size >= config.maxConcurrentSessions
|| currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress
|| !consumeConnectionPermit(remoteAddress)
) {
totalRejected += 1;
socket.destroy();
return;
}
const sessionRef = `session:${randomUUID()}`;
const session = {
sessionRef,
remoteAddress,
buffer: Buffer.alloc(0),
adapterSession: assertDeviceAdapterSession(
config.adapter.createSession({
profileRef: config.profile.profileRef,
}),
),
identifier: null,
sequence: 0,
state: "awaiting-header",
processing: false,
rejected: false,
closed: false,
pendingCommand: null,
};
sessions.set(socket, session);
incrementAddressSessions(remoteAddress);
totalAccepted += 1;
socket.setNoDelay(true);
socket.setTimeout(config.sessionTimeoutMs);
socket.on("data", (chunk) => {
if (session.closed || session.rejected) return;
socket.pause();
appendBuffer(session, chunk);
if (
session.buffer.length > config.maxBufferedBytes
|| totalBufferedBytes > config.maxAggregateBufferedBytes
) {
rejectSession(socket, session);
return;
}
if (session.processing) return;
session.processing = true;
void processSession(socket, session)
.catch(() => rejectSession(socket, session))
.finally(() => {
session.processing = false;
if (!session.closed && !session.rejected) socket.resume();
});
});
socket.on("timeout", () => rejectSession(socket, session));
socket.on("close", () => closeSession(socket, session));
socket.on("error", () => closeSession(socket, session));
});
const healthServer = createHttpServer((request, response) => {
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
if (request.method !== "GET" || request.url !== "/healthz") {
response.statusCode = 404;
return response.end('{"ok":false,"error":"device_gateway_route_not_found"}\n');
}
response.statusCode = 200;
return response.end(`${JSON.stringify({
ok: true,
service: "nodedc-device-gateway",
adapter: config.adapter?.adapterRef ?? "disabled",
protocolProfile: config.profile?.profileRef ?? "disabled",
framing: config.profile?.framing?.status ?? "disabled",
tcpListener: config.listenEnabled ? "telemetry-ingest" : "disabled",
publicIngress: config.publicIngressEnabled
? "telemetry-ingest"
: "disabled",
commandTransport: config.commandTransport,
sessions: {
active: sessions.size,
accepted: totalAccepted,
rejected: totalRejected,
discoveries: totalDiscoveries,
messagesAccepted: totalMessagesAccepted,
packagesAcknowledged: totalPackagesAcknowledged,
bufferedBytes: totalBufferedBytes,
},
})}\n`);
});
return {
async start() {
await listen(healthServer, config.healthPort, config.healthHost);
if (config.listenEnabled) {
await listen(tcpServer, config.tcpPort, config.tcpHost);
}
return {
healthAddress: healthServer.address(),
tcpAddress: config.listenEnabled ? tcpServer.address() : null,
};
},
async stop() {
for (const [socket, session] of sessions) {
closeSession(socket, session);
socket.destroy();
}
await Promise.all([
closeServer(healthServer),
config.listenEnabled ? closeServer(tcpServer) : Promise.resolve(),
]);
},
status() {
return {
adapter: config.adapter?.adapterRef ?? "disabled",
protocolProfile: config.profile?.profileRef ?? "disabled",
framing: config.profile?.framing?.status ?? "disabled",
activeSessions: sessions.size,
totalAccepted,
totalRejected,
totalDiscoveries,
totalMessagesAccepted,
totalPackagesAcknowledged,
totalBufferedBytes,
commandTransport: config.commandTransport,
publicIngress: config.publicIngressEnabled
? "telemetry-ingest"
: "disabled",
};
},
};
async function processSession(socket, session) {
while (!session.closed && !session.rejected) {
if (session.state === "awaiting-header") {
const parsed = session.adapterSession.parseHeader(session.buffer);
if (parsed.status === "incomplete") return;
const observedAt = config.now().toISOString();
const discovery = {
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
sessionRef: session.sessionRef,
...(config.routeRef ? { routeRef: config.routeRef } : {}),
modelProfileRef: config.profile.profileRef,
protocol: config.profile.protocol,
observedAt,
identifier: {
kind: parsed.identifier.kind,
value: parsed.identifier.value,
},
evidence: parsed.evidence,
};
const acceptedDiscovery = await config.onDiscovery(discovery);
assertDiscoveryAccepted(acceptedDiscovery);
session.identifier = discovery.identifier;
consumeBuffer(session, parsed.bytesConsumed);
session.state = "packages";
totalDiscoveries += 1;
await writeWithBackpressure(socket, session.adapterSession.buildHeaderAcknowledgement(
Math.floor(new Date(observedAt).getTime() / 1000),
));
if (acceptedDiscovery.commandOffer) {
await dispatchCommandOffer(
socket,
session,
acceptedDiscovery.commandOffer,
);
}
continue;
}
if (session.pendingCommand) {
const result = session.adapterSession.parseTypedCommandResponse(
session.buffer,
session.pendingCommand,
);
if (result.status === "incomplete") return;
if (result.status === "acknowledged") {
const command = session.pendingCommand;
consumeBuffer(session, result.bytesConsumed);
session.pendingCommand = null;
await config.onCommandStatus({
commandRef: command.commandRef,
transportMessageRef: command.transportMessageRef,
lifecycleState: "acknowledged",
resultCode: result.resultCode,
observedAt: config.now().toISOString(),
sessionRef: session.sessionRef,
adapterProfileRef: config.profile.profileRef,
});
continue;
}
}
const parsed = session.adapterSession.parseMessage(session.buffer);
if (parsed.status === "incomplete") return;
const observedAt = config.now().toISOString();
session.sequence += 1;
const message = normalizeAdapterMessage({
schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA,
edgeRef: config.edgeRef,
adapterRef: config.adapter.adapterRef,
protocolProfileRef: config.profile.profileRef,
protocol: config.profile.protocol,
sessionRef: session.sessionRef,
...(config.routeRef ? { routeRef: config.routeRef } : {}),
messageRef: messageRef(parsed),
messageType: parsed.messageType,
sequence: session.sequence,
observedAt,
idempotencyKey: messageIdempotencyKey({
adapterRef: config.adapter.adapterRef,
profileRef: config.profile.profileRef,
identifier: session.identifier,
payload: parsed.payload,
}),
identifier: session.identifier,
payloadSchemaRef: parsed.payloadSchemaRef,
payload: parsed.payload,
});
const acceptedMessage = await config.onMessage(message);
const {
commandOffer,
...acceptanceValue
} = acceptedMessage;
const acceptance = normalizeAdapterAcceptance(acceptanceValue);
if (acceptance.idempotencyKey !== message.idempotencyKey) {
throw new TypeError("device_gateway_core_acceptance_mismatch");
}
consumeBuffer(session, parsed.bytesConsumed);
totalMessagesAccepted += 1;
totalPackagesAcknowledged += 1;
await writeWithBackpressure(
socket,
session.adapterSession.buildMessageAcknowledgement(parsed),
);
if (commandOffer) {
await dispatchCommandOffer(
socket,
session,
commandOffer,
);
}
}
}
async function dispatchCommandOffer(socket, session, value) {
if (session.pendingCommand) {
throw new Error("device_gateway_command_already_pending");
}
const command = normalizeCommandOffer(value);
const bytes = session.adapterSession.buildTypedCommand(command);
session.pendingCommand = command;
await writeWithBackpressure(socket, bytes);
}
function currentAddressSessions(remoteAddress) {
return sessionsByAddress.get(remoteAddress) || 0;
}
function incrementAddressSessions(remoteAddress) {
sessionsByAddress.set(
remoteAddress,
currentAddressSessions(remoteAddress) + 1,
);
}
function decrementAddressSessions(remoteAddress) {
const current = currentAddressSessions(remoteAddress);
if (current <= 1) {
sessionsByAddress.delete(remoteAddress);
} else {
sessionsByAddress.set(remoteAddress, current - 1);
}
}
function consumeConnectionPermit(remoteAddress) {
const nowMs = config.now().getTime();
for (const [address, window] of connectionWindows) {
if (nowMs - window.startedAt >= 60_000) {
connectionWindows.delete(address);
}
}
const current = connectionWindows.get(remoteAddress);
if (!current || nowMs - current.startedAt >= 60_000) {
if (connectionWindows.size >= config.maxTrackedSourceAddresses) {
return false;
}
connectionWindows.set(remoteAddress, {
startedAt: nowMs,
count: 1,
});
return true;
}
if (current.count >= config.maxConnectionsPerMinutePerAddress) {
return false;
}
current.count += 1;
return true;
}
function rejectSession(socket, session) {
if (!session.rejected) {
session.rejected = true;
totalRejected += 1;
}
socket.destroy();
}
function closeSession(socket, session) {
if (session.closed) return;
session.closed = true;
totalBufferedBytes = Math.max(0, totalBufferedBytes - session.buffer.length);
session.buffer = Buffer.alloc(0);
if (session.pendingCommand && config.onCommandStatus) {
const command = session.pendingCommand;
session.pendingCommand = null;
void config.onCommandStatus({
commandRef: command.commandRef,
transportMessageRef: command.transportMessageRef,
lifecycleState: "unknown",
resultCode: "tracker_session_closed",
observedAt: config.now().toISOString(),
sessionRef: session.sessionRef,
adapterProfileRef: config.profile.profileRef,
}).catch(() => undefined);
}
sessions.delete(socket);
decrementAddressSessions(session.remoteAddress);
}
function appendBuffer(session, chunk) {
session.buffer = Buffer.concat(
[session.buffer, chunk],
session.buffer.length + chunk.length,
);
totalBufferedBytes += chunk.length;
}
function consumeBuffer(session, bytesConsumed) {
const consumed = Math.min(bytesConsumed, session.buffer.length);
session.buffer = session.buffer.subarray(consumed);
totalBufferedBytes = Math.max(0, totalBufferedBytes - consumed);
}
}
function normalizeConfig(input) {
const listenEnabled = input.listenEnabled === true;
const publicIngressEnabled = input.publicIngressEnabled === true;
if (publicIngressEnabled && !listenEnabled) {
throw new TypeError("device_gateway_public_ingress_listener_required");
}
if (publicIngressEnabled && input.coreChannelAuthenticated !== true) {
throw new TypeError("device_gateway_authenticated_core_channel_required");
}
if (
listenEnabled
&& (typeof input.onDiscovery !== "function"
|| typeof input.onMessage !== "function")
) {
throw new TypeError("device_gateway_core_acceptance_sink_required");
}
const registration = listenEnabled
? resolveAdapterRegistration(input.adapterRegistry, input.protocolProfileRef)
: null;
if (registration?.profile?.commandTransport?.status === "typed-service-ping-v1") {
const probe = assertDeviceAdapterSession(registration.adapter.createSession({
profileRef: registration.profile.profileRef,
}));
if (
typeof probe.buildTypedCommand !== "function"
|| typeof probe.parseTypedCommandResponse !== "function"
|| typeof input.onCommandStatus !== "function"
) {
throw new TypeError("device_gateway_typed_command_runtime_required");
}
}
return {
listenEnabled,
publicIngressEnabled,
adapter: registration?.adapter ?? null,
profile: registration?.profile ?? null,
commandTransport:
registration?.profile?.commandTransport?.status ?? "disabled",
edgeRef: listenEnabled
? normalizeOpaqueRef(input.edgeRef, "device_gateway_edge_ref_invalid")
: "edge:disabled",
routeRef: input.routeRef == null || input.routeRef === ""
? undefined
: normalizeRouteRef(input.routeRef),
healthHost: normalizeHealthHost(input.healthHost, "127.0.0.1"),
healthPort: parseInteger(
input.healthPort,
18121,
0,
65535,
"device_gateway_health_port_invalid",
),
tcpHost: normalizeTcpHost(
input.tcpHost,
publicIngressEnabled ? "0.0.0.0" : "127.0.0.1",
publicIngressEnabled,
),
tcpPort: parseInteger(
input.tcpPort,
9921,
0,
65535,
"device_gateway_tcp_port_invalid",
),
maxBufferedBytes: parseInteger(
input.maxBufferedBytes,
registration?.profile?.framing?.maxBufferedBytes ?? 256 * 1024,
1024,
registration?.profile?.framing?.maxBufferedBytes ?? 256 * 1024,
"device_gateway_buffer_limit_invalid",
),
maxAggregateBufferedBytes: parseInteger(
input.maxAggregateBufferedBytes,
32 * 1024 * 1024,
1024,
32 * 1024 * 1024,
"device_gateway_aggregate_buffer_limit_invalid",
),
maxConcurrentSessions: parseInteger(
input.maxConcurrentSessions,
128,
1,
10000,
"device_gateway_session_limit_invalid",
),
maxSessionsPerAddress: parseInteger(
input.maxSessionsPerAddress,
16,
1,
1000,
"device_gateway_address_session_limit_invalid",
),
maxConnectionsPerMinutePerAddress: parseInteger(
input.maxConnectionsPerMinutePerAddress,
60,
1,
10000,
"device_gateway_address_rate_limit_invalid",
),
maxTrackedSourceAddresses: parseInteger(
input.maxTrackedSourceAddresses,
2048,
1,
65536,
"device_gateway_source_tracking_limit_invalid",
),
sessionTimeoutMs: parseInteger(
input.sessionTimeoutMs,
10000,
100,
60000,
"device_gateway_session_timeout_invalid",
),
onDiscovery: typeof input.onDiscovery === "function"
? input.onDiscovery
: undefined,
onMessage: typeof input.onMessage === "function"
? input.onMessage
: undefined,
onCommandStatus: typeof input.onCommandStatus === "function"
? input.onCommandStatus
: undefined,
now: typeof input.now === "function" ? input.now : () => new Date(),
};
}
function normalizeCommandOffer(value) {
if (
!value
|| typeof value !== "object"
|| Array.isArray(value)
|| typeof value.commandRef !== "string"
|| !/^command:[0-9a-f-]{36}$/i.test(value.commandRef)
|| value.commandType !== "service.ping"
|| typeof value.accessCode !== "string"
|| !/^\d{6}$/.test(value.accessCode)
|| typeof value.transportMessageRef !== "string"
|| !/^edge-command:[0-9a-f-]{36}$/i.test(value.transportMessageRef)
) {
throw new TypeError("device_gateway_command_offer_invalid");
}
return Object.freeze({
commandRef: value.commandRef.toLowerCase(),
commandType: value.commandType,
accessCode: value.accessCode,
transportMessageRef: value.transportMessageRef.toLowerCase(),
});
}
function resolveAdapterRegistration(registry, profileRef) {
if (!registry || typeof registry.resolveProfile !== "function") {
throw new TypeError("device_gateway_adapter_registry_required");
}
return registry.resolveProfile(profileRef);
}
function normalizeOpaqueRef(value, errorCode) {
if (
typeof value !== "string"
|| !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)
) {
throw new TypeError(errorCode);
}
return value;
}
function normalizeRouteRef(value) {
if (
typeof value !== "string"
|| !/^route:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
) {
throw new TypeError("device_gateway_route_ref_invalid");
}
return value.toLowerCase();
}
function assertDiscoveryAccepted(value) {
if (
!value
|| typeof value !== "object"
|| !["quarantine", "claimed"].includes(value.lifecycleState)
) {
throw new TypeError("device_gateway_core_discovery_not_accepted");
}
}
function messageRef(parsed) {
const digest = String(parsed?.payload?.packageDigest || "");
if (!/^sha256:[a-f0-9]{64}$/.test(digest)) {
throw new TypeError("device_gateway_adapter_message_digest_invalid");
}
return `package:${parsed.packageNumber}:${digest.slice("sha256:".length)}`;
}
function messageIdempotencyKey({ adapterRef, profileRef, identifier, payload }) {
return `sha256:${createHash("sha256")
.update(JSON.stringify({ adapterRef, profileRef, identifier, payload }), "utf8")
.digest("hex")}`;
}
function writeWithBackpressure(socket, bytes) {
if (!Buffer.isBuffer(bytes) || bytes.length === 0 || bytes.length > 4096) {
throw new TypeError("device_gateway_adapter_ack_invalid");
}
if (socket.write(bytes)) return Promise.resolve();
return new Promise((resolve, reject) => {
const onDrain = () => {
socket.off("error", onError);
resolve();
};
const onError = (error) => {
socket.off("drain", onDrain);
reject(error);
};
socket.once("drain", onDrain);
socket.once("error", onError);
});
}
function normalizeHealthHost(value, fallback) {
const normalized = String(value || fallback).trim();
if (!["127.0.0.1", "::1", "0.0.0.0", "::"].includes(normalized)) {
throw new TypeError("device_gateway_health_host_invalid");
}
return normalized;
}
function normalizeTcpHost(value, fallback, publicIngressEnabled) {
const normalized = String(value || fallback).trim();
const allowed = publicIngressEnabled
? ["0.0.0.0", "::"]
: ["127.0.0.1", "::1"];
if (!allowed.includes(normalized)) {
throw new TypeError(
publicIngressEnabled
? "device_gateway_public_ingress_host_invalid"
: "device_gateway_baseline_loopback_only",
);
}
return normalized;
}
function normalizeRemoteAddress(value) {
const normalized = String(value || "unknown").trim();
return normalized.slice(0, 64) || "unknown";
}
function parseInteger(value, fallback, minimum, maximum, errorCode) {
const parsed = Number(value ?? fallback);
if (
!Number.isSafeInteger(parsed)
|| parsed < minimum
|| parsed > maximum
) {
throw new TypeError(errorCode);
}
return parsed;
}
function listen(server, port, host) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, resolve);
});
}
function closeServer(server) {
if (!server.listening) return Promise.resolve();
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
+175
View File
@@ -0,0 +1,175 @@
import { readFile } from "node:fs/promises";
import {
DEVICE_ADAPTER_CATALOG,
} from "../../../packages/device-adapter-catalog/src/index.mjs";
import { createCoreGatewayClient } from "./core-client.mjs";
import { createDeviceGatewayRuntime } from "./runtime.mjs";
const config = await readConfig();
const coreClient = config.listenEnabled
? createCoreGatewayClient({
coreUrl: config.coreUrl,
gatewayToken: config.gatewayToken,
timeoutMs: config.coreTimeoutMs,
})
: undefined;
const runtime = createDeviceGatewayRuntime({
listenEnabled: config.listenEnabled,
publicIngressEnabled: config.publicIngressEnabled,
coreChannelAuthenticated: false,
adapterRegistry: DEVICE_ADAPTER_CATALOG.registry,
protocolProfileRef: config.protocolProfileRef,
edgeRef: config.edgeRef,
routeRef: config.routeRef,
healthHost: config.healthHost,
healthPort: config.healthPort,
tcpHost: config.tcpHost,
tcpPort: config.tcpPort,
maxBufferedBytes: config.maxBufferedBytes,
maxAggregateBufferedBytes: config.maxAggregateBufferedBytes,
maxConcurrentSessions: config.maxConcurrentSessions,
maxSessionsPerAddress: config.maxSessionsPerAddress,
maxConnectionsPerMinutePerAddress:
config.maxConnectionsPerMinutePerAddress,
maxTrackedSourceAddresses: config.maxTrackedSourceAddresses,
sessionTimeoutMs: config.sessionTimeoutMs,
onDiscovery: coreClient?.observeDiscovery,
onMessage: coreClient?.acceptMessage,
});
const addresses = await runtime.start();
console.log(JSON.stringify({
event: "device_gateway_started",
health: addresses.healthAddress,
tcp: addresses.tcpAddress,
publicIngress: config.publicIngressEnabled
? "telemetry-ingest"
: "disabled",
commandTransport: "disabled",
}));
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
async function shutdown() {
await runtime.stop();
process.exit(0);
}
async function readConfig() {
const listenEnabled = parseBoolean(
process.env.DEVICE_GATEWAY_LISTEN_ENABLED,
false,
);
const publicIngressEnabled = parseBoolean(
process.env.DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED,
false,
);
return {
listenEnabled,
publicIngressEnabled,
protocolProfileRef: String(
process.env.DEVICE_GATEWAY_PROTOCOL_PROFILE_REF
|| DEVICE_ADAPTER_CATALOG.defaultProfileRef,
),
edgeRef: listenEnabled
? requiredValue(
process.env.DEVICE_GATEWAY_EDGE_REF,
"device_gateway_edge_ref_required",
)
: "edge:disabled",
routeRef: String(process.env.DEVICE_GATEWAY_ROUTE_REF || ""),
healthHost: String(
process.env.DEVICE_GATEWAY_HEALTH_HOST || "127.0.0.1",
),
healthPort: parsePort(process.env.DEVICE_GATEWAY_HEALTH_PORT, 18121),
tcpHost: String(
process.env.DEVICE_GATEWAY_TCP_HOST
|| (publicIngressEnabled ? "0.0.0.0" : "127.0.0.1"),
),
tcpPort: parsePort(process.env.DEVICE_GATEWAY_TCP_PORT, 9921),
maxBufferedBytes: parsePositiveInt(
process.env.DEVICE_GATEWAY_MAX_BUFFERED_BYTES,
65536,
),
maxAggregateBufferedBytes: parsePositiveInt(
process.env.DEVICE_GATEWAY_MAX_AGGREGATE_BUFFERED_BYTES,
32 * 1024 * 1024,
),
maxConcurrentSessions: parsePositiveInt(
process.env.DEVICE_GATEWAY_MAX_SESSIONS,
128,
),
maxSessionsPerAddress: parsePositiveInt(
process.env.DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS,
16,
),
maxConnectionsPerMinutePerAddress: parsePositiveInt(
process.env.DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS,
60,
),
maxTrackedSourceAddresses: parsePositiveInt(
process.env.DEVICE_GATEWAY_MAX_TRACKED_SOURCE_ADDRESSES,
2048,
),
sessionTimeoutMs: parsePositiveInt(
process.env.DEVICE_GATEWAY_SESSION_TIMEOUT_MS,
10000,
),
coreUrl: listenEnabled
? requiredValue(
process.env.DEVICE_GATEWAY_CORE_URL,
"device_gateway_core_url_required",
)
: "",
gatewayToken: listenEnabled
? await readRequiredSecretFile(
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
"device_gateway_core_token_file_required",
)
: "",
coreTimeoutMs: parsePositiveInt(
process.env.DEVICE_GATEWAY_CORE_TIMEOUT_MS,
5000,
),
};
}
async function readRequiredSecretFile(path, errorCode) {
const normalized = requiredValue(path, errorCode);
const value = (await readFile(normalized, "utf8")).trim();
if (value.length < 32) throw new Error(errorCode);
return value;
}
function requiredValue(value, errorCode) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(errorCode);
}
return value.trim();
}
function parsePort(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error("device_gateway_port_invalid");
}
return parsed;
}
function parsePositiveInt(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error("device_gateway_positive_integer_invalid");
}
return parsed;
}
function parseBoolean(value, fallback) {
if (value === undefined || value === null || value === "") return fallback;
const normalized = String(value).trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
throw new Error("device_gateway_boolean_invalid");
}