Primo commit - Prompt 02 — WebSocket endpoints /agent e /client + parsing robusto

This commit is contained in:
Michele Proietto
2026-01-08 17:10:47 +01:00
parent c9391dda7b
commit bb69e51ef1
19 changed files with 1991 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
{
"name": "@assistenza/protocol",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"devDependencies": {
"typescript": "^5.4.5"
}
}

View File

@@ -0,0 +1,210 @@
/*
Shared protocol v1 types and helpers.
Keep validation minimal and dependency-free.
*/
export const ProtocolVersion = 1 as const;
export type ProtocolVersion = typeof ProtocolVersion;
export type RequestId = string;
export type MessageBase = {
v: ProtocolVersion;
type: string;
requestId: RequestId;
};
// Agent -> Server
export type AgentRegister = MessageBase & {
type: "agent_register";
deviceId: string;
deviceName: string;
pairingKey: string;
};
export type AgentRegistered = MessageBase & {
type: "agent_registered";
deviceId: string;
};
export type AgentHeartbeat = MessageBase & {
type: "agent_heartbeat";
deviceId: string;
ts: number;
};
// Server -> Agent
export type ServerConnectRequest = MessageBase & {
type: "server_connect_request";
deviceId: string;
sessionId: string;
clientId: string;
};
export type AgentConnectResponse = MessageBase & {
type: "agent_connect_response";
deviceId: string;
sessionId: string;
accepted: boolean;
reason?: string;
};
// Client -> Server
export type ClientLogin = MessageBase & {
type: "client_login";
username: string;
password: string;
};
export type ClientLoginResult = MessageBase & {
type: "client_login_result";
ok: boolean;
clientId?: string;
message?: string;
};
export type ClientListDevices = MessageBase & {
type: "client_list_devices";
};
export type ClientDeviceList = MessageBase & {
type: "client_device_list";
devices: Array<{
deviceId: string;
deviceName: string;
online: boolean;
}>;
};
export type ClientConnectRequest = MessageBase & {
type: "client_connect_request";
deviceId: string;
};
export type ClientConnectStatus = MessageBase & {
type: "client_connect_status";
deviceId: string;
sessionId: string;
status: "pending" | "accepted" | "denied" | "expired";
reason?: string;
};
export type ClientConnectResult = MessageBase & {
type: "client_connect_result";
deviceId: string;
sessionId: string;
ok: boolean;
message?: string;
};
// Error
export type ErrorMessage = MessageBase & {
type: "error";
code: "UNAUTHORIZED" | "BAD_REQUEST" | "NOT_FOUND" | "CONFLICT" | "DEVICE_OFFLINE";
message: string;
};
export type AnyMessage =
| AgentRegister
| AgentRegistered
| AgentHeartbeat
| ServerConnectRequest
| AgentConnectResponse
| ClientLogin
| ClientLoginResult
| ClientListDevices
| ClientDeviceList
| ClientConnectRequest
| ClientConnectStatus
| ClientConnectResult
| ErrorMessage;
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function hasString(obj: Record<string, unknown>, key: string): boolean {
return typeof obj[key] === "string" && obj[key] !== "";
}
function hasNumber(obj: Record<string, unknown>, key: string): boolean {
return typeof obj[key] === "number" && Number.isFinite(obj[key]);
}
export function isMessage(obj: unknown): obj is AnyMessage {
if (!isObject(obj)) return false;
if (obj.v !== ProtocolVersion) return false;
if (!hasString(obj, "type")) return false;
if (!hasString(obj, "requestId")) return false;
switch (obj.type) {
case "agent_register":
return hasString(obj, "deviceId") && hasString(obj, "deviceName") && hasString(obj, "pairingKey");
case "agent_registered":
return hasString(obj, "deviceId");
case "agent_heartbeat":
return hasString(obj, "deviceId") && hasNumber(obj, "ts");
case "server_connect_request":
return hasString(obj, "deviceId") && hasString(obj, "sessionId") && hasString(obj, "clientId");
case "agent_connect_response":
return (
hasString(obj, "deviceId") &&
hasString(obj, "sessionId") &&
typeof obj.accepted === "boolean"
);
case "client_login":
return hasString(obj, "username") && hasString(obj, "password");
case "client_login_result":
return typeof obj.ok === "boolean";
case "client_list_devices":
return true;
case "client_device_list":
return Array.isArray(obj.devices);
case "client_connect_request":
return hasString(obj, "deviceId");
case "client_connect_status":
return (
hasString(obj, "deviceId") &&
hasString(obj, "sessionId") &&
hasString(obj, "status")
);
case "client_connect_result":
return (
hasString(obj, "deviceId") &&
hasString(obj, "sessionId") &&
typeof obj.ok === "boolean"
);
case "error":
return hasString(obj, "code") && hasString(obj, "message");
default:
return false;
}
}
export function nowTs(): number {
return Date.now();
}
// Minimal UUID v4 (RFC4122) generator without dependencies.
export function makeRequestId(): string {
const bytes = new Uint8Array(16);
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
return (
hex.slice(0, 4).join("") + "-" +
hex.slice(4, 6).join("") + "-" +
hex.slice(6, 8).join("") + "-" +
hex.slice(8, 10).join("") + "-" +
hex.slice(10, 16).join("")
);
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}