Files
NODEDC_PLATFORM/device-plane/packages/device-edge-channel-contract/src/index.mjs
T

388 lines
12 KiB
JavaScript

export const DEVICE_EDGE_CHANNEL_SCHEMA =
"nodedc.device-edge.channel-envelope.v1";
export const DEVICE_EDGE_CHANNEL_PATH =
"/internal/v1/device-edge/channel";
export const DEVICE_EDGE_CHANNEL_LIMITS = Object.freeze({
maxEnvelopeBytes: 1024 * 1024,
maxPendingAcceptances: 128,
keepaliveMs: 15_000,
deadPeerMs: 45_000,
reconnectMinimumMs: 1_000,
reconnectMaximumMs: 30_000,
});
export const EDGE_TO_CORE_MESSAGE_KINDS = Object.freeze([
"channel.hello",
"channel.heartbeat",
"tracker.session-opened",
"tracker.session-closed",
"discovery.observed",
"adapter.message",
"delivery.acknowledged",
"command.status",
"channel.counters",
]);
export const CORE_TO_EDGE_MESSAGE_KINDS = Object.freeze([
"channel.accepted",
"channel.heartbeat",
"flow.window",
"session.disposition",
"event.accepted",
"event.rejected",
]);
const OPAQUE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
const forbiddenKeyFragments = Object.freeze([
"password",
"secret",
"credential",
"authorization",
"privatekey",
"bearertoken",
]);
export function normalizeChannelEnvelope(input, options = {}) {
assertPlainObject(input, "device_edge_channel_envelope");
const direction = normalizeDirection(options.direction);
const maxEnvelopeBytes = normalizeLimit(
options.maxEnvelopeBytes,
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
);
const allowedKeys = new Set([
"schemaVersion",
"edgeRegistrationId",
"channelGeneration",
"trackerSessionId",
"adapterProfileRef",
"sequence",
"eventAt",
"receivedAt",
"payloadBytes",
"messageKind",
"correlationId",
"payload",
]);
rejectUnexpectedKeys(input, allowedKeys);
rejectForbiddenKeys(input);
if (input.schemaVersion !== DEVICE_EDGE_CHANNEL_SCHEMA) {
throw new TypeError("device_edge_channel_schema_invalid");
}
const allowedKinds = direction === "edge-to-core"
? EDGE_TO_CORE_MESSAGE_KINDS
: CORE_TO_EDGE_MESSAGE_KINDS;
if (!allowedKinds.includes(input.messageKind)) {
throw new TypeError("device_edge_channel_message_kind_invalid");
}
const sequence = Number(input.sequence);
if (!Number.isSafeInteger(sequence) || sequence < 1) {
throw new TypeError("device_edge_channel_sequence_invalid");
}
assertJsonValue(input.payload, 0);
const payload = cloneJsonValue(input.payload);
const payloadBytes = Buffer.byteLength(JSON.stringify(payload), "utf8");
if (payloadBytes > maxEnvelopeBytes) {
throw new TypeError("device_edge_channel_payload_too_large");
}
if (Number(input.payloadBytes) !== payloadBytes) {
throw new TypeError("device_edge_channel_payload_length_mismatch");
}
const normalized = {
schemaVersion: DEVICE_EDGE_CHANNEL_SCHEMA,
edgeRegistrationId: normalizeRef(
input.edgeRegistrationId,
"edge_registration_id",
),
channelGeneration: normalizeRef(
input.channelGeneration,
"channel_generation",
),
trackerSessionId: normalizeRef(
input.trackerSessionId,
"tracker_session_id",
),
adapterProfileRef: normalizeRef(
input.adapterProfileRef,
"adapter_profile_ref",
),
sequence,
eventAt: normalizeTimestamp(input.eventAt, "event_at"),
receivedAt: normalizeTimestamp(input.receivedAt, "received_at"),
payloadBytes,
messageKind: input.messageKind,
correlationId: normalizeRef(input.correlationId, "correlation_id"),
payload,
};
return deepFreeze(normalized);
}
export function createChannelEnvelope(fields, options = {}) {
assertPlainObject(fields, "device_edge_channel_fields");
const payload = fields.payload ?? {};
assertJsonValue(payload, 0);
const clonedPayload = cloneJsonValue(payload);
return normalizeChannelEnvelope({
...fields,
schemaVersion: DEVICE_EDGE_CHANNEL_SCHEMA,
payloadBytes: Buffer.byteLength(JSON.stringify(clonedPayload), "utf8"),
payload: clonedPayload,
}, options);
}
export function encodeChannelEnvelope(envelope, options = {}) {
const normalized = normalizeChannelEnvelope(envelope, options);
const encoded = Buffer.from(`${JSON.stringify(normalized)}\n`, "utf8");
const maxEnvelopeBytes = normalizeLimit(
options.maxEnvelopeBytes,
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
);
if (encoded.length > maxEnvelopeBytes) {
throw new TypeError("device_edge_channel_envelope_too_large");
}
return encoded;
}
export function createChannelEnvelopeDecoder(options = {}) {
const direction = normalizeDirection(options.direction);
const maxEnvelopeBytes = normalizeLimit(
options.maxEnvelopeBytes,
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
);
let buffered = Buffer.alloc(0);
return Object.freeze({
push(chunk) {
if (!Buffer.isBuffer(chunk) && !(chunk instanceof Uint8Array)) {
throw new TypeError("device_edge_channel_chunk_invalid");
}
let incoming = Buffer.from(chunk);
const envelopes = [];
while (incoming.length > 0) {
const newlineIndex = incoming.indexOf(0x0a);
if (newlineIndex < 0) {
if (buffered.length + incoming.length > maxEnvelopeBytes) {
throw new TypeError("device_edge_channel_envelope_too_large");
}
buffered = buffered.length === 0
? incoming
: Buffer.concat([buffered, incoming]);
break;
}
if (buffered.length + newlineIndex === 0) {
throw new TypeError("device_edge_channel_envelope_empty");
}
if (buffered.length + newlineIndex + 1 > maxEnvelopeBytes) {
throw new TypeError("device_edge_channel_envelope_too_large");
}
const segment = incoming.subarray(0, newlineIndex);
const line = buffered.length === 0
? segment
: Buffer.concat([buffered, segment]);
buffered = Buffer.alloc(0);
incoming = incoming.subarray(newlineIndex + 1);
let parsed;
try {
parsed = JSON.parse(line.toString("utf8"));
} catch {
throw new TypeError("device_edge_channel_envelope_json_invalid");
}
envelopes.push(normalizeChannelEnvelope(parsed, {
direction,
maxEnvelopeBytes,
}));
}
return envelopes;
},
finish() {
if (buffered.length !== 0) {
throw new TypeError("device_edge_channel_envelope_truncated");
}
},
bufferedBytes() {
return buffered.length;
},
});
}
export function normalizeCertificateFingerprint(value) {
const compact = String(value || "").replaceAll(":", "").toUpperCase();
if (!/^[A-F0-9]{64}$/.test(compact)) {
throw new TypeError("device_edge_channel_certificate_fingerprint_invalid");
}
return compact.match(/.{2}/g).join(":");
}
export function normalizeCertificateIdentities(value) {
if (!Array.isArray(value) || value.length < 1 || value.length > 2) {
throw new TypeError("device_edge_channel_certificate_identities_invalid");
}
const generations = new Set();
const fingerprints = new Set();
let activeCount = 0;
const identities = value.map((identity) => {
assertPlainObject(identity, "device_edge_channel_certificate_identity");
rejectUnexpectedKeys(
identity,
new Set(["generationRef", "fingerprint", "status"]),
);
if (!["active", "staged"].includes(identity.status)) {
throw new TypeError("device_edge_channel_certificate_identity_status_invalid");
}
if (identity.status === "active") activeCount += 1;
const generationRef = normalizeRef(
identity.generationRef,
"certificate_generation_ref",
);
const fingerprint = normalizeCertificateFingerprint(identity.fingerprint);
if (generations.has(generationRef) || fingerprints.has(fingerprint)) {
throw new TypeError("device_edge_channel_certificate_identity_duplicate");
}
generations.add(generationRef);
fingerprints.add(fingerprint);
return Object.freeze({
generationRef,
fingerprint,
status: identity.status,
});
});
if (activeCount !== 1) {
throw new TypeError("device_edge_channel_active_certificate_identity_invalid");
}
return Object.freeze(identities);
}
export function nextReconnectDelay(attempt, options = {}) {
const normalizedAttempt = Number(attempt);
if (!Number.isSafeInteger(normalizedAttempt) || normalizedAttempt < 0) {
throw new TypeError("device_edge_channel_reconnect_attempt_invalid");
}
const minimumMs = normalizeReconnectDuration(
options.minimumMs,
DEVICE_EDGE_CHANNEL_LIMITS.reconnectMinimumMs,
);
const maximumMs = normalizeReconnectDuration(
options.maximumMs,
DEVICE_EDGE_CHANNEL_LIMITS.reconnectMaximumMs,
);
if (maximumMs < minimumMs) {
throw new TypeError("device_edge_channel_reconnect_range_invalid");
}
const random = options.random ?? Math.random;
if (typeof random !== "function") {
throw new TypeError("device_edge_channel_random_invalid");
}
const ceiling = Math.min(maximumMs, minimumMs * (2 ** normalizedAttempt));
const floor = Math.max(minimumMs, Math.floor(ceiling / 2));
const sample = Number(random());
if (!Number.isFinite(sample) || sample < 0 || sample > 1) {
throw new TypeError("device_edge_channel_random_invalid");
}
return Math.floor(floor + ((ceiling - floor) * sample));
}
function normalizeReconnectDuration(value, fallback) {
const number = value == null ? fallback : Number(value);
if (!Number.isSafeInteger(number) || number < 10 || number > 120_000) {
throw new TypeError("device_edge_channel_reconnect_duration_invalid");
}
return number;
}
function normalizeDirection(value) {
if (!['edge-to-core', 'core-to-edge'].includes(value)) {
throw new TypeError("device_edge_channel_direction_invalid");
}
return value;
}
function normalizeLimit(value, fallback) {
const number = value == null ? fallback : Number(value);
if (!Number.isSafeInteger(number) || number < 256 || number > 4 * 1024 * 1024) {
throw new TypeError("device_edge_channel_limit_invalid");
}
return number;
}
function normalizeRef(value, field) {
if (typeof value !== "string" || !OPAQUE_REF_RE.test(value)) {
throw new TypeError(`device_edge_channel_${field}_invalid`);
}
return value;
}
function normalizeTimestamp(value, field) {
if (typeof value !== "string" || !ISO_TIMESTAMP_RE.test(value)) {
throw new TypeError(`device_edge_channel_${field}_invalid`);
}
const date = new Date(value);
if (!Number.isFinite(date.getTime())) {
throw new TypeError(`device_edge_channel_${field}_invalid`);
}
return date.toISOString();
}
function assertPlainObject(value, name) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${name}_invalid`);
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError(`${name}_invalid`);
}
}
function rejectUnexpectedKeys(value, allowedKeys) {
for (const key of Object.keys(value)) {
if (!allowedKeys.has(key)) {
throw new TypeError(`device_edge_channel_field_unexpected:${key}`);
}
}
}
function rejectForbiddenKeys(value, depth = 0) {
if (depth > 16 || value == null || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
const compact = key.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
if (forbiddenKeyFragments.some((fragment) => compact.includes(fragment))) {
throw new TypeError(`device_edge_channel_forbidden_field:${key}`);
}
rejectForbiddenKeys(child, depth + 1);
}
}
function assertJsonValue(value, depth) {
if (depth > 16) {
throw new TypeError("device_edge_channel_payload_depth_invalid");
}
if (value == null || typeof value === "string" || typeof value === "boolean") {
return;
}
if (typeof value === "number" && Number.isFinite(value)) return;
if (Array.isArray(value)) {
for (const child of value) assertJsonValue(child, depth + 1);
return;
}
assertPlainObject(value, "device_edge_channel_payload");
for (const child of Object.values(value)) {
assertJsonValue(child, depth + 1);
}
}
function cloneJsonValue(value) {
return JSON.parse(JSON.stringify(value));
}
function deepFreeze(value) {
if (!value || typeof value !== "object" || Object.isFrozen(value)) {
return value;
}
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
return value;
}