388 lines
12 KiB
JavaScript
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;
|
|
}
|