fix(deploy): keep manager releases on stable runner
This commit is contained in:
@@ -1,19 +1,16 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app/services/device-control-core
|
||||
|
||||
COPY services/device-control-core/package.json services/device-control-core/package-lock.json ./
|
||||
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/device-protocol-contract ./packages/device-protocol-contract
|
||||
COPY packages/device-edge-channel-contract ./packages/device-edge-channel-contract
|
||||
COPY packages/device-adapter-runtime/package.json ./packages/device-adapter-runtime/package.json
|
||||
COPY packages/device-adapter-catalog/package.json ./packages/device-adapter-catalog/package.json
|
||||
COPY packages/arusnavi-b2-adapter/package.json ./packages/arusnavi-b2-adapter/package.json
|
||||
COPY services/device-control-core ./services/device-control-core
|
||||
COPY services/device-gateway/package.json ./services/device-gateway/package.json
|
||||
COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json
|
||||
COPY services/device-gateway-core ./services/device-gateway-core
|
||||
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
|
||||
USER node
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"name": "@nodedc/device-control-core",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@nodedc/device-control-core",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"pg": "8.22.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
|
||||
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"pg": "^8.18.0"
|
||||
"pg": "8.22.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { connect as connectHttp2 } from "node:http2";
|
||||
|
||||
import {
|
||||
DEVICE_EDGE_CHANNEL_LIMITS,
|
||||
DEVICE_EDGE_CHANNEL_PATH,
|
||||
createChannelEnvelope,
|
||||
createChannelEnvelopeDecoder,
|
||||
encodeChannelEnvelope,
|
||||
nextReconnectDelay,
|
||||
normalizeCertificateIdentities,
|
||||
normalizeCertificateFingerprint,
|
||||
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
|
||||
import {
|
||||
normalizeAdapterMessage,
|
||||
normalizeDiscoverySignal,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
|
||||
// Runtime-owned transport implementation; kept inside the deployable Core context.
|
||||
const CHANNEL_TRACKER_SESSION_ID = "channel:control";
|
||||
const CHANNEL_PROFILE_REF = "channel.control.v1";
|
||||
|
||||
export function createDeviceGatewayCoreChannelClient(options = {}) {
|
||||
const config = normalizeConfig(options);
|
||||
const readyWaiters = new Set();
|
||||
let running = false;
|
||||
let state = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectAttempt = 0;
|
||||
let connectionSerial = 0;
|
||||
let totalConnectionAttempts = 0;
|
||||
let totalChannelsAccepted = 0;
|
||||
let totalReconnects = 0;
|
||||
let totalEventsAccepted = 0;
|
||||
let totalEventsRejected = 0;
|
||||
let totalProtocolFailures = 0;
|
||||
let lastErrorCode = null;
|
||||
|
||||
return Object.freeze({
|
||||
async start() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
void connectNow();
|
||||
},
|
||||
async stop() {
|
||||
running = false;
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
const current = state;
|
||||
state = null;
|
||||
if (current) closeConnection(current, false);
|
||||
rejectReadyWaiters("device_gateway_core_channel_stopped");
|
||||
},
|
||||
waitForReady(timeoutMs = 5_000) {
|
||||
if (state?.ready && !state.closed) return Promise.resolve(status());
|
||||
const normalizedTimeout = normalizeInteger(
|
||||
timeoutMs,
|
||||
10,
|
||||
120_000,
|
||||
5_000,
|
||||
"ready_timeout",
|
||||
);
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter = { resolve, reject, timer: null };
|
||||
waiter.timer = setTimeout(() => {
|
||||
readyWaiters.delete(waiter);
|
||||
reject(new Error("device_gateway_core_channel_ready_timeout"));
|
||||
}, normalizedTimeout);
|
||||
waiter.timer.unref?.();
|
||||
readyWaiters.add(waiter);
|
||||
});
|
||||
},
|
||||
status,
|
||||
disconnect() {
|
||||
if (state) closeConnection(state, true);
|
||||
},
|
||||
});
|
||||
|
||||
function status() {
|
||||
return Object.freeze({
|
||||
running,
|
||||
channel: state?.ready ? "accepted" : state ? "connecting" : "absent",
|
||||
edgeRegistrationId: state?.registration?.edgeRegistrationId ?? null,
|
||||
channelGeneration: state?.channelGeneration ?? null,
|
||||
edgeTrustGeneration: state?.observedEdgeIdentity?.generationRef ?? null,
|
||||
edgeCertificateFingerprint:
|
||||
state?.observedEdgeIdentity?.fingerprint ?? null,
|
||||
activeTrackerSessionChains: state?.sessionChains.size ?? 0,
|
||||
connectionAttempts: totalConnectionAttempts,
|
||||
channelsAccepted: totalChannelsAccepted,
|
||||
reconnects: totalReconnects,
|
||||
eventsAccepted: totalEventsAccepted,
|
||||
eventsRejected: totalEventsRejected,
|
||||
protocolFailures: totalProtocolFailures,
|
||||
lastErrorCode,
|
||||
trackerIngress: "remote-edge-only",
|
||||
commandTransport: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
async function connectNow() {
|
||||
if (!running || state) return;
|
||||
totalConnectionAttempts += 1;
|
||||
const serial = ++connectionSerial;
|
||||
let registration;
|
||||
try {
|
||||
registration = normalizeRegistration(await config.registrationProvider());
|
||||
if (registration.lifecycleState !== "active") {
|
||||
throw new Error("device_gateway_core_edge_registration_inactive");
|
||||
}
|
||||
} catch (error) {
|
||||
lastErrorCode = safeErrorCode(error);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = {
|
||||
serial,
|
||||
registration,
|
||||
session: null,
|
||||
request: null,
|
||||
decoder: createChannelEnvelopeDecoder({
|
||||
direction: "edge-to-core",
|
||||
maxEnvelopeBytes: config.maxEnvelopeBytes,
|
||||
}),
|
||||
sessionChains: new Map(),
|
||||
channelGeneration: null,
|
||||
observedEdgeIdentity: null,
|
||||
edgeSequence: 0,
|
||||
coreSequence: 0,
|
||||
lastEdgeActivityAt: config.clock(),
|
||||
heartbeatTimer: null,
|
||||
ready: false,
|
||||
closed: false,
|
||||
};
|
||||
state = connection;
|
||||
const endpoint = new URL(registration.endpoint);
|
||||
const authority = `${endpoint.protocol}//${endpoint.host}`;
|
||||
const session = connectHttp2(authority, {
|
||||
key: config.tls.key,
|
||||
cert: config.tls.cert,
|
||||
ca: config.tls.ca,
|
||||
minVersion: "TLSv1.3",
|
||||
maxVersion: "TLSv1.3",
|
||||
rejectUnauthorized: true,
|
||||
servername: registration.servername,
|
||||
ALPNProtocols: ["h2"],
|
||||
settings: {
|
||||
enablePush: false,
|
||||
initialWindowSize: 1024 * 1024,
|
||||
},
|
||||
});
|
||||
connection.session = session;
|
||||
session.once("error", (error) => failConnection(connection, error));
|
||||
session.once("close", () => closeConnection(connection, true));
|
||||
session.once("connect", () => {
|
||||
try {
|
||||
verifyEdgePeer(connection);
|
||||
openChannelStream(connection);
|
||||
} catch (error) {
|
||||
failConnection(connection, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openChannelStream(connection) {
|
||||
assertCurrent(connection);
|
||||
const request = connection.session.request({
|
||||
":method": "POST",
|
||||
":path": DEVICE_EDGE_CHANNEL_PATH,
|
||||
"content-type": "application/x-ndjson",
|
||||
"cache-control": "no-store",
|
||||
}, { endStream: false });
|
||||
connection.request = request;
|
||||
request.once("response", (headers) => {
|
||||
if (Number(headers[":status"]) !== 200) {
|
||||
failConnection(connection, new Error(
|
||||
`device_gateway_core_channel_http_status_${headers[":status"]}`,
|
||||
));
|
||||
}
|
||||
});
|
||||
let processing = Promise.resolve();
|
||||
request.on("data", (chunk) => {
|
||||
request.pause();
|
||||
processing = processing
|
||||
.then(async () => {
|
||||
const envelopes = connection.decoder.push(chunk);
|
||||
for (const envelope of envelopes) {
|
||||
await handleEdgeEnvelope(connection, envelope);
|
||||
}
|
||||
})
|
||||
.catch((error) => failConnection(connection, error))
|
||||
.finally(() => {
|
||||
if (!connection.closed) request.resume();
|
||||
});
|
||||
});
|
||||
request.once("aborted", () => closeConnection(connection, true));
|
||||
request.once("close", () => closeConnection(connection, true));
|
||||
request.once("error", (error) => failConnection(connection, error));
|
||||
connection.heartbeatTimer = setInterval(
|
||||
() => checkChannelHealth(connection),
|
||||
config.keepaliveMs,
|
||||
);
|
||||
connection.heartbeatTimer.unref?.();
|
||||
}
|
||||
|
||||
async function handleEdgeEnvelope(connection, envelope) {
|
||||
assertCurrent(connection);
|
||||
if (
|
||||
envelope.edgeRegistrationId !== connection.registration.edgeRegistrationId
|
||||
|| envelope.sequence !== connection.edgeSequence + 1
|
||||
) {
|
||||
throw new Error("device_gateway_core_edge_envelope_mismatch");
|
||||
}
|
||||
if (
|
||||
connection.channelGeneration
|
||||
&& envelope.channelGeneration !== connection.channelGeneration
|
||||
) {
|
||||
throw new Error("device_gateway_core_channel_generation_mismatch");
|
||||
}
|
||||
connection.edgeSequence = envelope.sequence;
|
||||
connection.lastEdgeActivityAt = config.clock();
|
||||
|
||||
if (!connection.ready) {
|
||||
if (envelope.messageKind !== "channel.hello") {
|
||||
throw new Error("device_gateway_core_channel_hello_required");
|
||||
}
|
||||
if (
|
||||
envelope.channelGeneration !== connection.registration.channelGeneration
|
||||
) {
|
||||
throw new Error("device_gateway_core_channel_generation_mismatch");
|
||||
}
|
||||
if (
|
||||
envelope.payload?.status !== "ready"
|
||||
|| envelope.payload?.transport !== "http2-mtls"
|
||||
|| envelope.payload?.trustGeneration
|
||||
!== connection.observedEdgeIdentity?.generationRef
|
||||
|| envelope.payload?.commandTransport !== "disabled"
|
||||
) {
|
||||
throw new Error("device_gateway_core_channel_hello_invalid");
|
||||
}
|
||||
connection.channelGeneration = connection.registration.channelGeneration;
|
||||
send(connection, "channel.accepted", {
|
||||
status: "accepted",
|
||||
coreIdentity: config.coreIdentity,
|
||||
commandTransport: "disabled",
|
||||
}, {
|
||||
trackerSessionId: CHANNEL_TRACKER_SESSION_ID,
|
||||
adapterProfileRef: CHANNEL_PROFILE_REF,
|
||||
correlationId: envelope.correlationId,
|
||||
});
|
||||
connection.ready = true;
|
||||
reconnectAttempt = 0;
|
||||
totalChannelsAccepted += 1;
|
||||
lastErrorCode = null;
|
||||
resolveReadyWaiters();
|
||||
return;
|
||||
}
|
||||
if (envelope.messageKind === "channel.heartbeat") return;
|
||||
if (["discovery.observed", "adapter.message"].includes(envelope.messageKind)) {
|
||||
scheduleTrackerEvent(connection, envelope);
|
||||
return;
|
||||
}
|
||||
throw new Error("device_gateway_core_edge_message_unhandled");
|
||||
}
|
||||
|
||||
function scheduleTrackerEvent(connection, envelope) {
|
||||
if (envelope.trackerSessionId === CHANNEL_TRACKER_SESSION_ID) {
|
||||
throw new Error("device_gateway_core_tracker_session_invalid");
|
||||
}
|
||||
const previous = connection.sessionChains.get(envelope.trackerSessionId);
|
||||
if (!previous && connection.sessionChains.size >= 128) {
|
||||
throw new Error("device_gateway_core_tracker_session_limit_reached");
|
||||
}
|
||||
const work = (previous ?? Promise.resolve())
|
||||
.then(() => envelope.messageKind === "discovery.observed"
|
||||
? acceptDiscovery(connection, envelope)
|
||||
: acceptAdapterMessage(connection, envelope))
|
||||
.catch((error) => failConnection(connection, error))
|
||||
.finally(() => {
|
||||
if (connection.sessionChains.get(envelope.trackerSessionId) === work) {
|
||||
connection.sessionChains.delete(envelope.trackerSessionId);
|
||||
}
|
||||
});
|
||||
connection.sessionChains.set(envelope.trackerSessionId, work);
|
||||
}
|
||||
|
||||
async function acceptDiscovery(connection, envelope) {
|
||||
try {
|
||||
const signal = normalizeDiscoverySignal(envelope.payload?.signal);
|
||||
const discovery = await config.observeDiscovery(signal);
|
||||
sendEventResult(connection, envelope, { discovery });
|
||||
totalEventsAccepted += 1;
|
||||
} catch (error) {
|
||||
sendEventRejection(connection, envelope, error);
|
||||
totalEventsRejected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptAdapterMessage(connection, envelope) {
|
||||
try {
|
||||
const message = normalizeAdapterMessage(envelope.payload?.message, {
|
||||
maxBytes: config.maxEnvelopeBytes,
|
||||
});
|
||||
const acceptance = await config.acceptMessage(message);
|
||||
sendEventResult(connection, envelope, { acceptance });
|
||||
totalEventsAccepted += 1;
|
||||
} catch (error) {
|
||||
sendEventRejection(connection, envelope, error);
|
||||
totalEventsRejected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function sendEventResult(connection, envelope, result) {
|
||||
send(connection, "event.accepted", { result }, {
|
||||
trackerSessionId: envelope.trackerSessionId,
|
||||
adapterProfileRef: envelope.adapterProfileRef,
|
||||
correlationId: envelope.correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
function sendEventRejection(connection, envelope, error) {
|
||||
send(connection, "event.rejected", {
|
||||
errorCode: safeErrorCode(error),
|
||||
}, {
|
||||
trackerSessionId: envelope.trackerSessionId,
|
||||
adapterProfileRef: envelope.adapterProfileRef,
|
||||
correlationId: envelope.correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
function send(connection, messageKind, payload, metadata) {
|
||||
assertCurrent(connection);
|
||||
if (!connection.channelGeneration) {
|
||||
throw new Error("device_gateway_core_channel_generation_absent");
|
||||
}
|
||||
connection.coreSequence += 1;
|
||||
const now = config.now();
|
||||
const envelope = createChannelEnvelope({
|
||||
edgeRegistrationId: connection.registration.edgeRegistrationId,
|
||||
channelGeneration: connection.channelGeneration,
|
||||
trackerSessionId: metadata.trackerSessionId,
|
||||
adapterProfileRef: metadata.adapterProfileRef,
|
||||
sequence: connection.coreSequence,
|
||||
eventAt: metadata.eventAt ?? now,
|
||||
receivedAt: now,
|
||||
messageKind,
|
||||
correlationId: metadata.correlationId,
|
||||
payload,
|
||||
}, {
|
||||
direction: "core-to-edge",
|
||||
maxEnvelopeBytes: config.maxEnvelopeBytes,
|
||||
});
|
||||
connection.request.write(encodeChannelEnvelope(envelope, {
|
||||
direction: "core-to-edge",
|
||||
maxEnvelopeBytes: config.maxEnvelopeBytes,
|
||||
}));
|
||||
}
|
||||
|
||||
function checkChannelHealth(connection) {
|
||||
if (connection.closed || state !== connection) return;
|
||||
if (config.clock() - connection.lastEdgeActivityAt >= config.deadPeerMs) {
|
||||
failConnection(connection, new Error("device_gateway_core_edge_dead_peer"));
|
||||
return;
|
||||
}
|
||||
if (connection.ready) {
|
||||
try {
|
||||
send(connection, "channel.heartbeat", { status: "alive" }, {
|
||||
trackerSessionId: CHANNEL_TRACKER_SESSION_ID,
|
||||
adapterProfileRef: CHANNEL_PROFILE_REF,
|
||||
correlationId: `correlation:${randomUUID()}`,
|
||||
});
|
||||
} catch (error) {
|
||||
failConnection(connection, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyEdgePeer(connection) {
|
||||
const socket = connection.session.socket;
|
||||
if (!socket?.authorized || socket.alpnProtocol !== "h2") {
|
||||
throw new Error("device_gateway_core_edge_tls_unauthorized");
|
||||
}
|
||||
const observed = normalizeCertificateFingerprint(
|
||||
socket.getPeerCertificate()?.fingerprint256,
|
||||
);
|
||||
const identity = connection.registration.certificateIdentities.find(
|
||||
(candidate) => candidate.fingerprint === observed,
|
||||
);
|
||||
if (!identity) {
|
||||
throw new Error("device_gateway_core_edge_identity_mismatch");
|
||||
}
|
||||
connection.observedEdgeIdentity = identity;
|
||||
}
|
||||
|
||||
function failConnection(connection, error) {
|
||||
if (connection.closed) return;
|
||||
totalProtocolFailures += 1;
|
||||
lastErrorCode = safeErrorCode(error);
|
||||
closeConnection(connection, true);
|
||||
}
|
||||
|
||||
function closeConnection(connection, reconnect) {
|
||||
if (connection.closed) return;
|
||||
connection.closed = true;
|
||||
clearInterval(connection.heartbeatTimer);
|
||||
connection.heartbeatTimer = null;
|
||||
connection.sessionChains.clear();
|
||||
try {
|
||||
connection.request?.close();
|
||||
} catch {}
|
||||
try {
|
||||
connection.session?.close();
|
||||
} catch {}
|
||||
if (state === connection) state = null;
|
||||
if (reconnect && running) scheduleReconnect();
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!running || reconnectTimer || state) return;
|
||||
const delay = nextReconnectDelay(reconnectAttempt, {
|
||||
minimumMs: config.reconnectMinimumMs,
|
||||
maximumMs: config.reconnectMaximumMs,
|
||||
random: config.random,
|
||||
});
|
||||
reconnectAttempt += 1;
|
||||
totalReconnects += 1;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
void connectNow();
|
||||
}, delay);
|
||||
reconnectTimer.unref?.();
|
||||
}
|
||||
|
||||
function assertCurrent(connection) {
|
||||
if (!running || connection.closed || state !== connection) {
|
||||
throw new Error("device_gateway_core_channel_unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReadyWaiters() {
|
||||
const value = status();
|
||||
for (const waiter of readyWaiters) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.resolve(value);
|
||||
}
|
||||
readyWaiters.clear();
|
||||
}
|
||||
|
||||
function rejectReadyWaiters(code) {
|
||||
for (const waiter of readyWaiters) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(new Error(code));
|
||||
}
|
||||
readyWaiters.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfig(options) {
|
||||
if (typeof options.observeDiscovery !== "function") {
|
||||
throw new TypeError("device_gateway_core_observe_discovery_invalid");
|
||||
}
|
||||
if (typeof options.acceptMessage !== "function") {
|
||||
throw new TypeError("device_gateway_core_accept_message_invalid");
|
||||
}
|
||||
const registrationProvider = typeof options.registrationProvider === "function"
|
||||
? options.registrationProvider
|
||||
: async () => options.registration;
|
||||
const tls = normalizeTls(options.tls);
|
||||
const keepaliveMs = normalizeInteger(
|
||||
options.keepaliveMs,
|
||||
10,
|
||||
120_000,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.keepaliveMs,
|
||||
"keepalive",
|
||||
);
|
||||
const deadPeerMs = normalizeInteger(
|
||||
options.deadPeerMs,
|
||||
keepaliveMs * 2,
|
||||
120_000,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.deadPeerMs,
|
||||
"dead_peer",
|
||||
);
|
||||
const reconnectMinimumMs = normalizeInteger(
|
||||
options.reconnectMinimumMs,
|
||||
10,
|
||||
120_000,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.reconnectMinimumMs,
|
||||
"reconnect_minimum",
|
||||
);
|
||||
const reconnectMaximumMs = normalizeInteger(
|
||||
options.reconnectMaximumMs,
|
||||
10,
|
||||
120_000,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.reconnectMaximumMs,
|
||||
"reconnect_maximum",
|
||||
);
|
||||
if (reconnectMaximumMs < reconnectMinimumMs) {
|
||||
throw new TypeError("device_gateway_core_reconnect_range_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
registrationProvider,
|
||||
tls,
|
||||
coreIdentity: normalizeRef(options.coreIdentity, "core_identity"),
|
||||
observeDiscovery: options.observeDiscovery,
|
||||
acceptMessage: options.acceptMessage,
|
||||
keepaliveMs,
|
||||
deadPeerMs,
|
||||
reconnectMinimumMs,
|
||||
reconnectMaximumMs,
|
||||
maxEnvelopeBytes: normalizeInteger(
|
||||
options.maxEnvelopeBytes,
|
||||
256,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
|
||||
"max_envelope_bytes",
|
||||
),
|
||||
random: typeof options.random === "function" ? options.random : Math.random,
|
||||
clock: typeof options.clock === "function" ? options.clock : Date.now,
|
||||
now: typeof options.now === "function"
|
||||
? () => new Date(options.now()).toISOString()
|
||||
: () => new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRegistration(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError("device_gateway_core_edge_registration_invalid");
|
||||
}
|
||||
let endpoint;
|
||||
try {
|
||||
endpoint = new URL(String(value.endpoint || ""));
|
||||
} catch {
|
||||
throw new TypeError("device_gateway_core_edge_endpoint_invalid");
|
||||
}
|
||||
if (
|
||||
endpoint.protocol !== "https:"
|
||||
|| endpoint.username
|
||||
|| endpoint.password
|
||||
|| endpoint.pathname !== "/"
|
||||
|| endpoint.search
|
||||
|| endpoint.hash
|
||||
) {
|
||||
throw new TypeError("device_gateway_core_edge_endpoint_invalid");
|
||||
}
|
||||
if (!["active", "revoked", "disabled"].includes(value.lifecycleState)) {
|
||||
throw new TypeError("device_gateway_core_edge_lifecycle_invalid");
|
||||
}
|
||||
const servername = String(value.servername || "");
|
||||
if (!/^[A-Za-z0-9.-]{1,253}$/.test(servername)) {
|
||||
throw new TypeError("device_gateway_core_edge_servername_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
edgeRegistrationId: normalizeRef(
|
||||
value.edgeRegistrationId,
|
||||
"edge_registration_id",
|
||||
),
|
||||
channelGeneration: normalizeRef(
|
||||
value.channelGeneration,
|
||||
"channel_generation",
|
||||
),
|
||||
endpoint: endpoint.toString(),
|
||||
servername,
|
||||
certificateIdentities: normalizeCertificateIdentities(
|
||||
value.certificateIdentities,
|
||||
),
|
||||
lifecycleState: value.lifecycleState,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTls(value) {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new TypeError("device_gateway_core_channel_tls_invalid");
|
||||
}
|
||||
for (const key of ["key", "cert", "ca"]) {
|
||||
if (!(typeof value[key] === "string" || Buffer.isBuffer(value[key]))) {
|
||||
throw new TypeError(`device_gateway_core_channel_tls_${key}_invalid`);
|
||||
}
|
||||
}
|
||||
return Object.freeze({ key: value.key, cert: value.cert, ca: value.ca });
|
||||
}
|
||||
|
||||
function safeErrorCode(error) {
|
||||
const value = String(error?.message || error || "device_gateway_core_error")
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9._:-]/g, "_")
|
||||
.slice(0, 128);
|
||||
return /^[a-z][a-z0-9._:-]{2,127}$/.test(value)
|
||||
? value
|
||||
: "device_gateway_core_event_rejected";
|
||||
}
|
||||
|
||||
function normalizeRef(value, field) {
|
||||
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) {
|
||||
throw new TypeError(`device_gateway_core_${field}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeInteger(value, minimum, maximum, fallback, field) {
|
||||
const number = value == null ? fallback : Number(value);
|
||||
if (!Number.isSafeInteger(number) || number < minimum || number > maximum) {
|
||||
throw new TypeError(`device_gateway_core_${field}_invalid`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
|
||||
import {
|
||||
createDeviceGatewayCoreChannelClient,
|
||||
} from "../../device-gateway-core/src/runtime.mjs";
|
||||
} from "./device-gateway-core-runtime.mjs";
|
||||
|
||||
const DEFAULT_TRUST_ROOT = "/run/nodedc-secrets/device-edge-channel/peers";
|
||||
|
||||
|
||||
@@ -1,605 +1,3 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { connect as connectHttp2 } from "node:http2";
|
||||
|
||||
import {
|
||||
DEVICE_EDGE_CHANNEL_LIMITS,
|
||||
DEVICE_EDGE_CHANNEL_PATH,
|
||||
createChannelEnvelope,
|
||||
createChannelEnvelopeDecoder,
|
||||
encodeChannelEnvelope,
|
||||
nextReconnectDelay,
|
||||
normalizeCertificateIdentities,
|
||||
normalizeCertificateFingerprint,
|
||||
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
|
||||
import {
|
||||
normalizeAdapterMessage,
|
||||
normalizeDiscoverySignal,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
|
||||
const CHANNEL_TRACKER_SESSION_ID = "channel:control";
|
||||
const CHANNEL_PROFILE_REF = "channel.control.v1";
|
||||
|
||||
export function createDeviceGatewayCoreChannelClient(options = {}) {
|
||||
const config = normalizeConfig(options);
|
||||
const readyWaiters = new Set();
|
||||
let running = false;
|
||||
let state = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectAttempt = 0;
|
||||
let connectionSerial = 0;
|
||||
let totalConnectionAttempts = 0;
|
||||
let totalChannelsAccepted = 0;
|
||||
let totalReconnects = 0;
|
||||
let totalEventsAccepted = 0;
|
||||
let totalEventsRejected = 0;
|
||||
let totalProtocolFailures = 0;
|
||||
let lastErrorCode = null;
|
||||
|
||||
return Object.freeze({
|
||||
async start() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
void connectNow();
|
||||
},
|
||||
async stop() {
|
||||
running = false;
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
const current = state;
|
||||
state = null;
|
||||
if (current) closeConnection(current, false);
|
||||
rejectReadyWaiters("device_gateway_core_channel_stopped");
|
||||
},
|
||||
waitForReady(timeoutMs = 5_000) {
|
||||
if (state?.ready && !state.closed) return Promise.resolve(status());
|
||||
const normalizedTimeout = normalizeInteger(
|
||||
timeoutMs,
|
||||
10,
|
||||
120_000,
|
||||
5_000,
|
||||
"ready_timeout",
|
||||
);
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter = { resolve, reject, timer: null };
|
||||
waiter.timer = setTimeout(() => {
|
||||
readyWaiters.delete(waiter);
|
||||
reject(new Error("device_gateway_core_channel_ready_timeout"));
|
||||
}, normalizedTimeout);
|
||||
waiter.timer.unref?.();
|
||||
readyWaiters.add(waiter);
|
||||
});
|
||||
},
|
||||
status,
|
||||
disconnect() {
|
||||
if (state) closeConnection(state, true);
|
||||
},
|
||||
});
|
||||
|
||||
function status() {
|
||||
return Object.freeze({
|
||||
running,
|
||||
channel: state?.ready ? "accepted" : state ? "connecting" : "absent",
|
||||
edgeRegistrationId: state?.registration?.edgeRegistrationId ?? null,
|
||||
channelGeneration: state?.channelGeneration ?? null,
|
||||
edgeTrustGeneration: state?.observedEdgeIdentity?.generationRef ?? null,
|
||||
edgeCertificateFingerprint:
|
||||
state?.observedEdgeIdentity?.fingerprint ?? null,
|
||||
activeTrackerSessionChains: state?.sessionChains.size ?? 0,
|
||||
connectionAttempts: totalConnectionAttempts,
|
||||
channelsAccepted: totalChannelsAccepted,
|
||||
reconnects: totalReconnects,
|
||||
eventsAccepted: totalEventsAccepted,
|
||||
eventsRejected: totalEventsRejected,
|
||||
protocolFailures: totalProtocolFailures,
|
||||
lastErrorCode,
|
||||
trackerIngress: "remote-edge-only",
|
||||
commandTransport: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
async function connectNow() {
|
||||
if (!running || state) return;
|
||||
totalConnectionAttempts += 1;
|
||||
const serial = ++connectionSerial;
|
||||
let registration;
|
||||
try {
|
||||
registration = normalizeRegistration(await config.registrationProvider());
|
||||
if (registration.lifecycleState !== "active") {
|
||||
throw new Error("device_gateway_core_edge_registration_inactive");
|
||||
}
|
||||
} catch (error) {
|
||||
lastErrorCode = safeErrorCode(error);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = {
|
||||
serial,
|
||||
registration,
|
||||
session: null,
|
||||
request: null,
|
||||
decoder: createChannelEnvelopeDecoder({
|
||||
direction: "edge-to-core",
|
||||
maxEnvelopeBytes: config.maxEnvelopeBytes,
|
||||
}),
|
||||
sessionChains: new Map(),
|
||||
channelGeneration: null,
|
||||
observedEdgeIdentity: null,
|
||||
edgeSequence: 0,
|
||||
coreSequence: 0,
|
||||
lastEdgeActivityAt: config.clock(),
|
||||
heartbeatTimer: null,
|
||||
ready: false,
|
||||
closed: false,
|
||||
};
|
||||
state = connection;
|
||||
const endpoint = new URL(registration.endpoint);
|
||||
const authority = `${endpoint.protocol}//${endpoint.host}`;
|
||||
const session = connectHttp2(authority, {
|
||||
key: config.tls.key,
|
||||
cert: config.tls.cert,
|
||||
ca: config.tls.ca,
|
||||
minVersion: "TLSv1.3",
|
||||
maxVersion: "TLSv1.3",
|
||||
rejectUnauthorized: true,
|
||||
servername: registration.servername,
|
||||
ALPNProtocols: ["h2"],
|
||||
settings: {
|
||||
enablePush: false,
|
||||
initialWindowSize: 1024 * 1024,
|
||||
},
|
||||
});
|
||||
connection.session = session;
|
||||
session.once("error", (error) => failConnection(connection, error));
|
||||
session.once("close", () => closeConnection(connection, true));
|
||||
session.once("connect", () => {
|
||||
try {
|
||||
verifyEdgePeer(connection);
|
||||
openChannelStream(connection);
|
||||
} catch (error) {
|
||||
failConnection(connection, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openChannelStream(connection) {
|
||||
assertCurrent(connection);
|
||||
const request = connection.session.request({
|
||||
":method": "POST",
|
||||
":path": DEVICE_EDGE_CHANNEL_PATH,
|
||||
"content-type": "application/x-ndjson",
|
||||
"cache-control": "no-store",
|
||||
}, { endStream: false });
|
||||
connection.request = request;
|
||||
request.once("response", (headers) => {
|
||||
if (Number(headers[":status"]) !== 200) {
|
||||
failConnection(connection, new Error(
|
||||
`device_gateway_core_channel_http_status_${headers[":status"]}`,
|
||||
));
|
||||
}
|
||||
});
|
||||
let processing = Promise.resolve();
|
||||
request.on("data", (chunk) => {
|
||||
request.pause();
|
||||
processing = processing
|
||||
.then(async () => {
|
||||
const envelopes = connection.decoder.push(chunk);
|
||||
for (const envelope of envelopes) {
|
||||
await handleEdgeEnvelope(connection, envelope);
|
||||
}
|
||||
})
|
||||
.catch((error) => failConnection(connection, error))
|
||||
.finally(() => {
|
||||
if (!connection.closed) request.resume();
|
||||
});
|
||||
});
|
||||
request.once("aborted", () => closeConnection(connection, true));
|
||||
request.once("close", () => closeConnection(connection, true));
|
||||
request.once("error", (error) => failConnection(connection, error));
|
||||
connection.heartbeatTimer = setInterval(
|
||||
() => checkChannelHealth(connection),
|
||||
config.keepaliveMs,
|
||||
);
|
||||
connection.heartbeatTimer.unref?.();
|
||||
}
|
||||
|
||||
async function handleEdgeEnvelope(connection, envelope) {
|
||||
assertCurrent(connection);
|
||||
if (
|
||||
envelope.edgeRegistrationId !== connection.registration.edgeRegistrationId
|
||||
|| envelope.sequence !== connection.edgeSequence + 1
|
||||
) {
|
||||
throw new Error("device_gateway_core_edge_envelope_mismatch");
|
||||
}
|
||||
if (
|
||||
connection.channelGeneration
|
||||
&& envelope.channelGeneration !== connection.channelGeneration
|
||||
) {
|
||||
throw new Error("device_gateway_core_channel_generation_mismatch");
|
||||
}
|
||||
connection.edgeSequence = envelope.sequence;
|
||||
connection.lastEdgeActivityAt = config.clock();
|
||||
|
||||
if (!connection.ready) {
|
||||
if (envelope.messageKind !== "channel.hello") {
|
||||
throw new Error("device_gateway_core_channel_hello_required");
|
||||
}
|
||||
if (
|
||||
envelope.channelGeneration !== connection.registration.channelGeneration
|
||||
) {
|
||||
throw new Error("device_gateway_core_channel_generation_mismatch");
|
||||
}
|
||||
if (
|
||||
envelope.payload?.status !== "ready"
|
||||
|| envelope.payload?.transport !== "http2-mtls"
|
||||
|| envelope.payload?.trustGeneration
|
||||
!== connection.observedEdgeIdentity?.generationRef
|
||||
|| envelope.payload?.commandTransport !== "disabled"
|
||||
) {
|
||||
throw new Error("device_gateway_core_channel_hello_invalid");
|
||||
}
|
||||
connection.channelGeneration = connection.registration.channelGeneration;
|
||||
send(connection, "channel.accepted", {
|
||||
status: "accepted",
|
||||
coreIdentity: config.coreIdentity,
|
||||
commandTransport: "disabled",
|
||||
}, {
|
||||
trackerSessionId: CHANNEL_TRACKER_SESSION_ID,
|
||||
adapterProfileRef: CHANNEL_PROFILE_REF,
|
||||
correlationId: envelope.correlationId,
|
||||
});
|
||||
connection.ready = true;
|
||||
reconnectAttempt = 0;
|
||||
totalChannelsAccepted += 1;
|
||||
lastErrorCode = null;
|
||||
resolveReadyWaiters();
|
||||
return;
|
||||
}
|
||||
if (envelope.messageKind === "channel.heartbeat") return;
|
||||
if (["discovery.observed", "adapter.message"].includes(envelope.messageKind)) {
|
||||
scheduleTrackerEvent(connection, envelope);
|
||||
return;
|
||||
}
|
||||
throw new Error("device_gateway_core_edge_message_unhandled");
|
||||
}
|
||||
|
||||
function scheduleTrackerEvent(connection, envelope) {
|
||||
if (envelope.trackerSessionId === CHANNEL_TRACKER_SESSION_ID) {
|
||||
throw new Error("device_gateway_core_tracker_session_invalid");
|
||||
}
|
||||
const previous = connection.sessionChains.get(envelope.trackerSessionId);
|
||||
if (!previous && connection.sessionChains.size >= 128) {
|
||||
throw new Error("device_gateway_core_tracker_session_limit_reached");
|
||||
}
|
||||
const work = (previous ?? Promise.resolve())
|
||||
.then(() => envelope.messageKind === "discovery.observed"
|
||||
? acceptDiscovery(connection, envelope)
|
||||
: acceptAdapterMessage(connection, envelope))
|
||||
.catch((error) => failConnection(connection, error))
|
||||
.finally(() => {
|
||||
if (connection.sessionChains.get(envelope.trackerSessionId) === work) {
|
||||
connection.sessionChains.delete(envelope.trackerSessionId);
|
||||
}
|
||||
});
|
||||
connection.sessionChains.set(envelope.trackerSessionId, work);
|
||||
}
|
||||
|
||||
async function acceptDiscovery(connection, envelope) {
|
||||
try {
|
||||
const signal = normalizeDiscoverySignal(envelope.payload?.signal);
|
||||
const discovery = await config.observeDiscovery(signal);
|
||||
sendEventResult(connection, envelope, { discovery });
|
||||
totalEventsAccepted += 1;
|
||||
} catch (error) {
|
||||
sendEventRejection(connection, envelope, error);
|
||||
totalEventsRejected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptAdapterMessage(connection, envelope) {
|
||||
try {
|
||||
const message = normalizeAdapterMessage(envelope.payload?.message, {
|
||||
maxBytes: config.maxEnvelopeBytes,
|
||||
});
|
||||
const acceptance = await config.acceptMessage(message);
|
||||
sendEventResult(connection, envelope, { acceptance });
|
||||
totalEventsAccepted += 1;
|
||||
} catch (error) {
|
||||
sendEventRejection(connection, envelope, error);
|
||||
totalEventsRejected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function sendEventResult(connection, envelope, result) {
|
||||
send(connection, "event.accepted", { result }, {
|
||||
trackerSessionId: envelope.trackerSessionId,
|
||||
adapterProfileRef: envelope.adapterProfileRef,
|
||||
correlationId: envelope.correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
function sendEventRejection(connection, envelope, error) {
|
||||
send(connection, "event.rejected", {
|
||||
errorCode: safeErrorCode(error),
|
||||
}, {
|
||||
trackerSessionId: envelope.trackerSessionId,
|
||||
adapterProfileRef: envelope.adapterProfileRef,
|
||||
correlationId: envelope.correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
function send(connection, messageKind, payload, metadata) {
|
||||
assertCurrent(connection);
|
||||
if (!connection.channelGeneration) {
|
||||
throw new Error("device_gateway_core_channel_generation_absent");
|
||||
}
|
||||
connection.coreSequence += 1;
|
||||
const now = config.now();
|
||||
const envelope = createChannelEnvelope({
|
||||
edgeRegistrationId: connection.registration.edgeRegistrationId,
|
||||
channelGeneration: connection.channelGeneration,
|
||||
trackerSessionId: metadata.trackerSessionId,
|
||||
adapterProfileRef: metadata.adapterProfileRef,
|
||||
sequence: connection.coreSequence,
|
||||
eventAt: metadata.eventAt ?? now,
|
||||
receivedAt: now,
|
||||
messageKind,
|
||||
correlationId: metadata.correlationId,
|
||||
payload,
|
||||
}, {
|
||||
direction: "core-to-edge",
|
||||
maxEnvelopeBytes: config.maxEnvelopeBytes,
|
||||
});
|
||||
connection.request.write(encodeChannelEnvelope(envelope, {
|
||||
direction: "core-to-edge",
|
||||
maxEnvelopeBytes: config.maxEnvelopeBytes,
|
||||
}));
|
||||
}
|
||||
|
||||
function checkChannelHealth(connection) {
|
||||
if (connection.closed || state !== connection) return;
|
||||
if (config.clock() - connection.lastEdgeActivityAt >= config.deadPeerMs) {
|
||||
failConnection(connection, new Error("device_gateway_core_edge_dead_peer"));
|
||||
return;
|
||||
}
|
||||
if (connection.ready) {
|
||||
try {
|
||||
send(connection, "channel.heartbeat", { status: "alive" }, {
|
||||
trackerSessionId: CHANNEL_TRACKER_SESSION_ID,
|
||||
adapterProfileRef: CHANNEL_PROFILE_REF,
|
||||
correlationId: `correlation:${randomUUID()}`,
|
||||
});
|
||||
} catch (error) {
|
||||
failConnection(connection, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyEdgePeer(connection) {
|
||||
const socket = connection.session.socket;
|
||||
if (!socket?.authorized || socket.alpnProtocol !== "h2") {
|
||||
throw new Error("device_gateway_core_edge_tls_unauthorized");
|
||||
}
|
||||
const observed = normalizeCertificateFingerprint(
|
||||
socket.getPeerCertificate()?.fingerprint256,
|
||||
);
|
||||
const identity = connection.registration.certificateIdentities.find(
|
||||
(candidate) => candidate.fingerprint === observed,
|
||||
);
|
||||
if (!identity) {
|
||||
throw new Error("device_gateway_core_edge_identity_mismatch");
|
||||
}
|
||||
connection.observedEdgeIdentity = identity;
|
||||
}
|
||||
|
||||
function failConnection(connection, error) {
|
||||
if (connection.closed) return;
|
||||
totalProtocolFailures += 1;
|
||||
lastErrorCode = safeErrorCode(error);
|
||||
closeConnection(connection, true);
|
||||
}
|
||||
|
||||
function closeConnection(connection, reconnect) {
|
||||
if (connection.closed) return;
|
||||
connection.closed = true;
|
||||
clearInterval(connection.heartbeatTimer);
|
||||
connection.heartbeatTimer = null;
|
||||
connection.sessionChains.clear();
|
||||
try {
|
||||
connection.request?.close();
|
||||
} catch {}
|
||||
try {
|
||||
connection.session?.close();
|
||||
} catch {}
|
||||
if (state === connection) state = null;
|
||||
if (reconnect && running) scheduleReconnect();
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!running || reconnectTimer || state) return;
|
||||
const delay = nextReconnectDelay(reconnectAttempt, {
|
||||
minimumMs: config.reconnectMinimumMs,
|
||||
maximumMs: config.reconnectMaximumMs,
|
||||
random: config.random,
|
||||
});
|
||||
reconnectAttempt += 1;
|
||||
totalReconnects += 1;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
void connectNow();
|
||||
}, delay);
|
||||
reconnectTimer.unref?.();
|
||||
}
|
||||
|
||||
function assertCurrent(connection) {
|
||||
if (!running || connection.closed || state !== connection) {
|
||||
throw new Error("device_gateway_core_channel_unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReadyWaiters() {
|
||||
const value = status();
|
||||
for (const waiter of readyWaiters) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.resolve(value);
|
||||
}
|
||||
readyWaiters.clear();
|
||||
}
|
||||
|
||||
function rejectReadyWaiters(code) {
|
||||
for (const waiter of readyWaiters) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(new Error(code));
|
||||
}
|
||||
readyWaiters.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfig(options) {
|
||||
if (typeof options.observeDiscovery !== "function") {
|
||||
throw new TypeError("device_gateway_core_observe_discovery_invalid");
|
||||
}
|
||||
if (typeof options.acceptMessage !== "function") {
|
||||
throw new TypeError("device_gateway_core_accept_message_invalid");
|
||||
}
|
||||
const registrationProvider = typeof options.registrationProvider === "function"
|
||||
? options.registrationProvider
|
||||
: async () => options.registration;
|
||||
const tls = normalizeTls(options.tls);
|
||||
const keepaliveMs = normalizeInteger(
|
||||
options.keepaliveMs,
|
||||
10,
|
||||
120_000,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.keepaliveMs,
|
||||
"keepalive",
|
||||
);
|
||||
const deadPeerMs = normalizeInteger(
|
||||
options.deadPeerMs,
|
||||
keepaliveMs * 2,
|
||||
120_000,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.deadPeerMs,
|
||||
"dead_peer",
|
||||
);
|
||||
const reconnectMinimumMs = normalizeInteger(
|
||||
options.reconnectMinimumMs,
|
||||
10,
|
||||
120_000,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.reconnectMinimumMs,
|
||||
"reconnect_minimum",
|
||||
);
|
||||
const reconnectMaximumMs = normalizeInteger(
|
||||
options.reconnectMaximumMs,
|
||||
10,
|
||||
120_000,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.reconnectMaximumMs,
|
||||
"reconnect_maximum",
|
||||
);
|
||||
if (reconnectMaximumMs < reconnectMinimumMs) {
|
||||
throw new TypeError("device_gateway_core_reconnect_range_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
registrationProvider,
|
||||
tls,
|
||||
coreIdentity: normalizeRef(options.coreIdentity, "core_identity"),
|
||||
observeDiscovery: options.observeDiscovery,
|
||||
acceptMessage: options.acceptMessage,
|
||||
keepaliveMs,
|
||||
deadPeerMs,
|
||||
reconnectMinimumMs,
|
||||
reconnectMaximumMs,
|
||||
maxEnvelopeBytes: normalizeInteger(
|
||||
options.maxEnvelopeBytes,
|
||||
256,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
|
||||
"max_envelope_bytes",
|
||||
),
|
||||
random: typeof options.random === "function" ? options.random : Math.random,
|
||||
clock: typeof options.clock === "function" ? options.clock : Date.now,
|
||||
now: typeof options.now === "function"
|
||||
? () => new Date(options.now()).toISOString()
|
||||
: () => new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRegistration(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError("device_gateway_core_edge_registration_invalid");
|
||||
}
|
||||
let endpoint;
|
||||
try {
|
||||
endpoint = new URL(String(value.endpoint || ""));
|
||||
} catch {
|
||||
throw new TypeError("device_gateway_core_edge_endpoint_invalid");
|
||||
}
|
||||
if (
|
||||
endpoint.protocol !== "https:"
|
||||
|| endpoint.username
|
||||
|| endpoint.password
|
||||
|| endpoint.pathname !== "/"
|
||||
|| endpoint.search
|
||||
|| endpoint.hash
|
||||
) {
|
||||
throw new TypeError("device_gateway_core_edge_endpoint_invalid");
|
||||
}
|
||||
if (!["active", "revoked", "disabled"].includes(value.lifecycleState)) {
|
||||
throw new TypeError("device_gateway_core_edge_lifecycle_invalid");
|
||||
}
|
||||
const servername = String(value.servername || "");
|
||||
if (!/^[A-Za-z0-9.-]{1,253}$/.test(servername)) {
|
||||
throw new TypeError("device_gateway_core_edge_servername_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
edgeRegistrationId: normalizeRef(
|
||||
value.edgeRegistrationId,
|
||||
"edge_registration_id",
|
||||
),
|
||||
channelGeneration: normalizeRef(
|
||||
value.channelGeneration,
|
||||
"channel_generation",
|
||||
),
|
||||
endpoint: endpoint.toString(),
|
||||
servername,
|
||||
certificateIdentities: normalizeCertificateIdentities(
|
||||
value.certificateIdentities,
|
||||
),
|
||||
lifecycleState: value.lifecycleState,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTls(value) {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new TypeError("device_gateway_core_channel_tls_invalid");
|
||||
}
|
||||
for (const key of ["key", "cert", "ca"]) {
|
||||
if (!(typeof value[key] === "string" || Buffer.isBuffer(value[key]))) {
|
||||
throw new TypeError(`device_gateway_core_channel_tls_${key}_invalid`);
|
||||
}
|
||||
}
|
||||
return Object.freeze({ key: value.key, cert: value.cert, ca: value.ca });
|
||||
}
|
||||
|
||||
function safeErrorCode(error) {
|
||||
const value = String(error?.message || error || "device_gateway_core_error")
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9._:-]/g, "_")
|
||||
.slice(0, 128);
|
||||
return /^[a-z][a-z0-9._:-]{2,127}$/.test(value)
|
||||
? value
|
||||
: "device_gateway_core_event_rejected";
|
||||
}
|
||||
|
||||
function normalizeRef(value, field) {
|
||||
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) {
|
||||
throw new TypeError(`device_gateway_core_${field}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeInteger(value, minimum, maximum, fallback, field) {
|
||||
const number = value == null ? fallback : Number(value);
|
||||
if (!Number.isSafeInteger(number) || number < minimum || number > maximum) {
|
||||
throw new TypeError(`device_gateway_core_${field}_invalid`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
export {
|
||||
createDeviceGatewayCoreChannelClient,
|
||||
} from "../../device-control-core/src/device-gateway-core-runtime.mjs";
|
||||
|
||||
@@ -12,7 +12,7 @@ const devicePlaneRoot = resolve(platformRoot, "device-plane");
|
||||
const designRoot = resolve(process.env.NODEDC_DEVICE_MANAGER_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_DESIGN_GUIDELINE"));
|
||||
const managerRoot = resolve(designRoot, "apps/device-manager");
|
||||
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
|
||||
const [patchId = "device-manager-release-20260811-015", ...extra] = process.argv.slice(2);
|
||||
const [patchId = "device-manager-release-20260811-016", ...extra] = process.argv.slice(2);
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-device-manager-control-plane-artifact.mjs [patch-id]");
|
||||
|
||||
const entries = [
|
||||
@@ -22,13 +22,10 @@ const entries = [
|
||||
"docker-compose.device-manager.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/device-edge-channel-contract",
|
||||
"packages/device-adapter-runtime/package.json",
|
||||
"packages/device-adapter-catalog/package.json",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway/package.json",
|
||||
"services/device-edge-relay/package.json",
|
||||
"services/device-gateway-core",
|
||||
"services/device-manager",
|
||||
"deployment/device-manager-release-v2.json",
|
||||
];
|
||||
@@ -75,6 +72,7 @@ try {
|
||||
);
|
||||
for (const modulePath of [
|
||||
"services/device-control-core/src/sensitive-reference-management.mjs",
|
||||
"services/device-control-core/src/device-gateway-core-runtime.mjs",
|
||||
"packages/device-edge-channel-contract/src/index.mjs",
|
||||
]) {
|
||||
const coreImport = spawnSync(
|
||||
|
||||
@@ -299,13 +299,10 @@ DEVICE_PLANE_MANAGER_RELEASE_V2_ENTRIES = (
|
||||
DEVICE_PLANE_MANAGER_COMPOSE_REL,
|
||||
"packages/device-protocol-contract",
|
||||
"packages/device-edge-channel-contract",
|
||||
"packages/device-adapter-runtime/package.json",
|
||||
"packages/device-adapter-catalog/package.json",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway/package.json",
|
||||
"services/device-edge-relay/package.json",
|
||||
"services/device-gateway-core",
|
||||
"services/device-manager",
|
||||
DEVICE_PLANE_MANAGER_RELEASE_V2_REL,
|
||||
)
|
||||
@@ -3536,13 +3533,10 @@ def allowed_payload_path(component, rel):
|
||||
DEVICE_PLANE_MANAGER_V2_RECONCILIATION_REL,
|
||||
"packages/device-protocol-contract",
|
||||
"packages/device-edge-channel-contract",
|
||||
"packages/device-adapter-runtime/package.json",
|
||||
"packages/device-adapter-catalog/package.json",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
"services/device-edge-relay/package.json",
|
||||
"services/device-gateway-core",
|
||||
"services/device-manager",
|
||||
"services/device-backhaul-target",
|
||||
):
|
||||
@@ -3553,7 +3547,6 @@ def allowed_payload_path(component, rel):
|
||||
"packages/arusnavi-b2-adapter/",
|
||||
"services/device-control-core/",
|
||||
"services/device-gateway/",
|
||||
"services/device-gateway-core/",
|
||||
"services/device-manager/",
|
||||
"services/device-backhaul-target/",
|
||||
)):
|
||||
|
||||
@@ -211,17 +211,29 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("packages/device-edge-channel-contract", entries)
|
||||
self.assertIn(
|
||||
"payload/packages/device-adapter-runtime/package.json",
|
||||
"payload/services/device-control-core/package-lock.json",
|
||||
names,
|
||||
)
|
||||
self.assertIn(
|
||||
"payload/packages/device-adapter-catalog/package.json",
|
||||
"payload/services/device-control-core/src/device-gateway-core-runtime.mjs",
|
||||
names,
|
||||
)
|
||||
self.assertFalse(any(
|
||||
name.startswith("payload/packages/device-adapter-runtime/")
|
||||
or name.startswith("payload/packages/device-adapter-catalog/")
|
||||
or name.startswith("payload/services/device-gateway-core/")
|
||||
for name in names
|
||||
))
|
||||
core_dockerfile = (
|
||||
PLATFORM_ROOT / "device-plane/services/device-control-core/Dockerfile"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
"payload/services/device-gateway-core/src/runtime.mjs",
|
||||
names,
|
||||
"COPY services/device-control-core/package.json "
|
||||
"services/device-control-core/package-lock.json ./",
|
||||
core_dockerfile,
|
||||
)
|
||||
self.assertNotIn("COPY package.json package-lock.json ./", core_dockerfile)
|
||||
self.assertNotIn("COPY services/device-gateway-core", core_dockerfile)
|
||||
self.assertFalse(any(name.endswith(".test.mjs") for name in names))
|
||||
self.assertEqual(result["services"], ["device-control-core", "device-manager"])
|
||||
self.assertNotIn("device-postgres", result["services"])
|
||||
|
||||
@@ -108,11 +108,8 @@ class DevicePlaneRegistryTest(unittest.TestCase):
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract/src/index.mjs",
|
||||
"packages/device-edge-channel-contract/src/index.mjs",
|
||||
"packages/device-adapter-runtime/package.json",
|
||||
"packages/device-adapter-catalog/package.json",
|
||||
"services/device-control-core/Dockerfile",
|
||||
"services/device-gateway/src/runtime.mjs",
|
||||
"services/device-gateway-core/src/runtime.mjs",
|
||||
):
|
||||
self.assertTrue(
|
||||
RUNNER.allowed_payload_path("device-plane", allowed),
|
||||
@@ -123,6 +120,9 @@ class DevicePlaneRegistryTest(unittest.TestCase):
|
||||
"secrets/postgres-password",
|
||||
"runtime/postgres/data",
|
||||
"services/unknown/server.mjs",
|
||||
"packages/device-adapter-runtime/package.json",
|
||||
"packages/device-adapter-catalog/package.json",
|
||||
"services/device-gateway-core/src/runtime.mjs",
|
||||
"docker-compose.yml",
|
||||
"services/device-control-core/start.sh",
|
||||
"services/device-control-core/test/app.test.mjs",
|
||||
|
||||
Reference in New Issue
Block a user