feat(device-plane): add universal adapter acceptance boundary
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@nodedc/device-adapter-runtime",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
export const DEVICE_ADAPTER_CONTRACT_VERSION =
|
||||
"nodedc.device-adapter.v1";
|
||||
|
||||
const ADAPTER_REF_RE = /^[a-z][a-z0-9-]{1,62}$/;
|
||||
const PROFILE_REF_RE = /^[a-z][a-z0-9._-]{2,127}$/;
|
||||
const RUNTIME_PACKAGE_REF_RE = /^@[a-z0-9-]+\/[a-z0-9-]+$/;
|
||||
|
||||
export function defineDeviceAdapter(input) {
|
||||
assertPlainObject(input, "device_adapter");
|
||||
if (input.contractVersion !== DEVICE_ADAPTER_CONTRACT_VERSION) {
|
||||
throw new TypeError("device_adapter_contract_version_invalid");
|
||||
}
|
||||
const adapterRef = normalizeRef(
|
||||
input.adapterRef,
|
||||
ADAPTER_REF_RE,
|
||||
"device_adapter_ref_invalid",
|
||||
);
|
||||
const runtimePackageRef = normalizeRef(
|
||||
input.runtimePackageRef,
|
||||
RUNTIME_PACKAGE_REF_RE,
|
||||
"device_adapter_runtime_package_ref_invalid",
|
||||
);
|
||||
if (!Array.isArray(input.profiles) || input.profiles.length === 0) {
|
||||
throw new TypeError("device_adapter_profiles_required");
|
||||
}
|
||||
const profiles = input.profiles.map((profile) =>
|
||||
normalizeProfile(profile, adapterRef)
|
||||
);
|
||||
if (new Set(profiles.map((profile) => profile.profileRef)).size !== profiles.length) {
|
||||
throw new TypeError("device_adapter_profile_duplicate");
|
||||
}
|
||||
if (typeof input.createSession !== "function") {
|
||||
throw new TypeError("device_adapter_session_factory_required");
|
||||
}
|
||||
|
||||
return deepFreeze({
|
||||
contractVersion: DEVICE_ADAPTER_CONTRACT_VERSION,
|
||||
adapterRef,
|
||||
runtimePackageRef,
|
||||
profiles,
|
||||
createSession: input.createSession,
|
||||
});
|
||||
}
|
||||
|
||||
export function createDeviceAdapterRegistry({ adapters = [] } = {}) {
|
||||
if (!Array.isArray(adapters)) {
|
||||
throw new TypeError("device_adapter_registry_adapters_invalid");
|
||||
}
|
||||
const byAdapterRef = new Map();
|
||||
const byProfileRef = new Map();
|
||||
for (const adapter of adapters) {
|
||||
const normalized = defineDeviceAdapter(adapter);
|
||||
if (byAdapterRef.has(normalized.adapterRef)) {
|
||||
throw new TypeError("device_adapter_registry_adapter_duplicate");
|
||||
}
|
||||
byAdapterRef.set(normalized.adapterRef, normalized);
|
||||
for (const profile of normalized.profiles) {
|
||||
if (byProfileRef.has(profile.profileRef)) {
|
||||
throw new TypeError("device_adapter_registry_profile_duplicate");
|
||||
}
|
||||
byProfileRef.set(profile.profileRef, Object.freeze({
|
||||
adapter: normalized,
|
||||
profile,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
adapterRefs: Object.freeze([...byAdapterRef.keys()].sort()),
|
||||
profileRefs: Object.freeze([...byProfileRef.keys()].sort()),
|
||||
getAdapter(adapterRef) {
|
||||
const normalized = normalizeRef(
|
||||
adapterRef,
|
||||
ADAPTER_REF_RE,
|
||||
"device_adapter_ref_invalid",
|
||||
);
|
||||
const adapter = byAdapterRef.get(normalized);
|
||||
if (!adapter) throw new TypeError("device_adapter_not_allowlisted");
|
||||
return adapter;
|
||||
},
|
||||
resolveProfile(profileRef) {
|
||||
const normalized = normalizeRef(
|
||||
profileRef,
|
||||
PROFILE_REF_RE,
|
||||
"device_adapter_profile_ref_invalid",
|
||||
);
|
||||
const registration = byProfileRef.get(normalized);
|
||||
if (!registration) {
|
||||
throw new TypeError("device_adapter_profile_not_allowlisted");
|
||||
}
|
||||
return registration;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function assertDeviceAdapterSession(session) {
|
||||
assertPlainObject(session, "device_adapter_session");
|
||||
for (const method of [
|
||||
"parseHeader",
|
||||
"buildHeaderAcknowledgement",
|
||||
"parseMessage",
|
||||
"buildMessageAcknowledgement",
|
||||
]) {
|
||||
if (typeof session[method] !== "function") {
|
||||
throw new TypeError(`device_adapter_session_method_missing:${method}`);
|
||||
}
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
function normalizeProfile(profile, adapterRef) {
|
||||
assertPlainObject(profile, "device_adapter_profile");
|
||||
const profileRef = normalizeRef(
|
||||
profile.profileRef,
|
||||
PROFILE_REF_RE,
|
||||
"device_adapter_profile_ref_invalid",
|
||||
);
|
||||
if (profile.adapterRef != null && profile.adapterRef !== adapterRef) {
|
||||
throw new TypeError("device_adapter_profile_adapter_mismatch");
|
||||
}
|
||||
const maxBufferedBytes = Number(profile?.framing?.maxBufferedBytes);
|
||||
if (
|
||||
!Number.isSafeInteger(maxBufferedBytes)
|
||||
|| maxBufferedBytes < 1024
|
||||
|| maxBufferedBytes > 256 * 1024
|
||||
) {
|
||||
throw new TypeError("device_adapter_profile_buffer_limit_invalid");
|
||||
}
|
||||
return deepFreeze({ ...profile, profileRef, adapterRef });
|
||||
}
|
||||
|
||||
function normalizeRef(value, pattern, errorCode) {
|
||||
if (typeof value !== "string" || !pattern.test(value)) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertPlainObject(value, label) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${label}_invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== "object" || Object.isFrozen(value)) {
|
||||
return value;
|
||||
}
|
||||
Object.values(value).forEach(deepFreeze);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEVICE_ADAPTER_CONTRACT_VERSION,
|
||||
createDeviceAdapterRegistry,
|
||||
defineDeviceAdapter,
|
||||
} from "../src/index.mjs";
|
||||
|
||||
function adapter(adapterRef = "generic-tracker", profileRef = "generic.tracker.v1") {
|
||||
return {
|
||||
contractVersion: DEVICE_ADAPTER_CONTRACT_VERSION,
|
||||
adapterRef,
|
||||
runtimePackageRef: `@nodedc/${adapterRef}-adapter`,
|
||||
profiles: [{
|
||||
profileRef,
|
||||
framing: { maxBufferedBytes: 64 * 1024 },
|
||||
}],
|
||||
createSession: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
test("resolves only explicitly allowlisted adapter profiles", () => {
|
||||
const registry = createDeviceAdapterRegistry({
|
||||
adapters: [adapter()],
|
||||
});
|
||||
assert.deepEqual(registry.adapterRefs, ["generic-tracker"]);
|
||||
assert.deepEqual(registry.profileRefs, ["generic.tracker.v1"]);
|
||||
assert.equal(
|
||||
registry.resolveProfile("generic.tracker.v1").adapter.adapterRef,
|
||||
"generic-tracker",
|
||||
);
|
||||
assert.throws(
|
||||
() => registry.resolveProfile("unknown.tracker.v1"),
|
||||
/device_adapter_profile_not_allowlisted/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects duplicate adapters and cross-adapter profile collisions", () => {
|
||||
assert.throws(
|
||||
() => createDeviceAdapterRegistry({ adapters: [adapter(), adapter()] }),
|
||||
/device_adapter_registry_adapter_duplicate/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createDeviceAdapterRegistry({
|
||||
adapters: [
|
||||
adapter("generic-tracker", "shared.profile.v1"),
|
||||
adapter("other-tracker", "shared.profile.v1"),
|
||||
],
|
||||
}),
|
||||
/device_adapter_registry_profile_duplicate/,
|
||||
);
|
||||
});
|
||||
|
||||
test("freezes adapter metadata but keeps the session factory callable", () => {
|
||||
const defined = defineDeviceAdapter(adapter());
|
||||
assert.equal(Object.isFrozen(defined), true);
|
||||
assert.equal(Object.isFrozen(defined.profiles[0]), true);
|
||||
assert.equal(typeof defined.createSession, "function");
|
||||
});
|
||||
Reference in New Issue
Block a user