feat: establish standalone Device Core repository
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user