Primo commit - Prompt 02 — WebSocket endpoints /agent e /client + parsing robusto
This commit is contained in:
23
server/Dockerfile
Normal file
23
server/Dockerfile
Normal 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
25
server/package.json
Normal 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
135
server/src/index.ts
Normal 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
8
server/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user