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

23
server/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:20-alpine
WORKDIR /app
RUN corepack enable
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY shared/protocol/package.json shared/protocol/package.json
COPY server/package.json server/package.json
RUN pnpm install --frozen-lockfile
COPY shared/protocol shared/protocol
COPY server server
RUN pnpm -r build
WORKDIR /app/server
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/index.js"]

25
server/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@assistenza/server",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"@assistenza/protocol": "workspace:*",
"dotenv": "^16.4.5",
"fastify": "^4.28.1",
"pino": "^9.3.2",
"ws": "^8.17.1"
},
"devDependencies": {
"@types/node": "^20.12.12",
"tsx": "^4.15.6",
"typescript": "^5.4.5"
}
}

135
server/src/index.ts Normal file
View File

@@ -0,0 +1,135 @@
import "dotenv/config";
import Fastify from "fastify";
import pino from "pino";
import { WebSocketServer } from "ws";
import type { RawData, WebSocket } from "ws";
import type { IncomingMessage } from "http";
import { ProtocolVersion, isMessage, nowTs } from "@assistenza/protocol";
export function serverBanner(): string {
return `server v${ProtocolVersion} ${nowTs()}`;
}
const logger = pino({ level: process.env.LOG_LEVEL ?? "info" });
const app = Fastify({ logger });
app.get("/health", async (_request, reply) => {
reply.code(200).type("text/plain").send("ok");
});
type WsKind = "agent" | "client";
function getRequestId(value: unknown): string | undefined {
if (typeof value !== "object" || value === null) return undefined;
const record = value as Record<string, unknown>;
return typeof record.requestId === "string" ? record.requestId : undefined;
}
function getPath(request: IncomingMessage): string {
if (!request.url) return "unknown";
try {
return new URL(request.url, "http://localhost").pathname;
} catch {
return request.url;
}
}
function getIp(request: IncomingMessage): string {
return request.socket.remoteAddress ?? "unknown";
}
function sendBadRequest(ws: WebSocket, requestId: string | undefined, message: string): void {
const payload: {
v: typeof ProtocolVersion;
type: "error";
code: "BAD_REQUEST";
message: string;
requestId?: string;
} = {
v: ProtocolVersion,
type: "error",
code: "BAD_REQUEST",
message,
...(requestId ? { requestId } : {}),
};
ws.send(JSON.stringify(payload));
}
function rawToString(data: RawData): string {
if (typeof data === "string") return data;
if (data instanceof Buffer) return data.toString("utf8");
if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
return Buffer.from(data).toString("utf8");
}
function handleConnection(kind: WsKind, ws: WebSocket, request: IncomingMessage): void {
const path = getPath(request);
const ip = getIp(request);
app.log.info({ ip, path, kind }, "ws connect");
ws.on("message", (data) => {
const raw = rawToString(data);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
sendBadRequest(ws, undefined, "Invalid JSON");
return;
}
const requestId = getRequestId(parsed);
if (!isMessage(parsed)) {
sendBadRequest(ws, requestId, "Invalid message shape");
return;
}
});
ws.on("close", (code, reason) => {
app.log.info(
{ ip, path, kind, code, reason: reason.toString() },
"ws disconnect"
);
});
ws.on("error", (err) => {
app.log.warn({ ip, path, kind, err }, "ws error");
});
}
const agentWs = new WebSocketServer({ noServer: true });
const clientWs = new WebSocketServer({ noServer: true });
agentWs.on("connection", (ws, request) => handleConnection("agent", ws, request));
clientWs.on("connection", (ws, request) => handleConnection("client", ws, request));
app.server.on("upgrade", (request, socket, head) => {
const path = getPath(request);
if (path === "/agent") {
agentWs.handleUpgrade(request, socket, head, (ws) => {
agentWs.emit("connection", ws, request);
});
return;
}
if (path === "/client") {
clientWs.handleUpgrade(request, socket, head, (ws) => {
clientWs.emit("connection", ws, request);
});
return;
}
socket.destroy();
});
async function start() {
const port = Number(process.env.PORT ?? "3000");
const host = process.env.HOST ?? "0.0.0.0";
await app.listen({ port, host });
app.log.info({ port, host }, serverBanner());
}
start().catch((err) => {
app.log.error(err, "failed to start server");
process.exit(1);
});

8
server/tsconfig.json Normal file
View File

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