feat: setup project
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@idle/server",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@idle/shared": "workspace:*",
|
||||
"sirv": "^3.0.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"tsx": "^4.20.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* Дымовой тест M0: сервер должен быть запущен. `pnpm --filter @idle/server smoke` */
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const seen = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket('ws://localhost:3000/ws');
|
||||
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 1 })));
|
||||
ws.on('message', (d) => {
|
||||
seen.push(JSON.parse(String(d)));
|
||||
if (seen.length >= 2) {
|
||||
ws.close();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
ws.on('error', reject);
|
||||
setTimeout(() => reject(new Error('timeout waiting for welcome+snapshot')), 5000);
|
||||
});
|
||||
|
||||
const [welcome, snap] = seen;
|
||||
console.log('welcome:', JSON.stringify(welcome));
|
||||
console.log(
|
||||
'avatars:',
|
||||
snap.avatars.map((a) => `${a.name}@${a.x},${a.y} ${a.face}`).join(' | '),
|
||||
);
|
||||
if (welcome.t !== 'welcome' || welcome.v !== 1) throw new Error('bad welcome');
|
||||
if (snap.t !== 'snapshot' || snap.avatars.length !== 3) throw new Error('bad snapshot');
|
||||
|
||||
// неверная версия протокола — сервер обязан закрыть сокет с кодом 4001
|
||||
const closeCode = await new Promise((resolve) => {
|
||||
const ws = new WebSocket('ws://localhost:3000/ws');
|
||||
ws.on('open', () => ws.send(JSON.stringify({ t: 'hello', v: 999 })));
|
||||
ws.on('close', (code) => resolve(code));
|
||||
setTimeout(() => resolve('no-close'), 3000);
|
||||
});
|
||||
console.log('close code for wrong version:', closeCode);
|
||||
if (closeCode !== 4001) throw new Error(`expected close 4001, got ${closeCode}`);
|
||||
|
||||
console.log('WS smoke: OK');
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createServer } from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import sirv from 'sirv';
|
||||
import { PROTOCOL_VERSION, type ServerMessage } from '@idle/shared';
|
||||
import { WsGateway } from './net/ws';
|
||||
import { stubAvatarsAt } from './sim/stub';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3000);
|
||||
const TICK_MS = Number(process.env.TICK_MS ?? 1000);
|
||||
const startedAt = Date.now();
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const currentTick = (): number => Math.floor((Date.now() - startedAt) / TICK_MS);
|
||||
|
||||
const snapshot = (): ServerMessage => ({
|
||||
t: 'snapshot',
|
||||
tick: currentTick(),
|
||||
avatars: stubAvatarsAt(Date.now()),
|
||||
});
|
||||
|
||||
const gateway = new WsGateway({ getSnapshot: snapshot, tickMs: TICK_MS });
|
||||
|
||||
// Собранные клиенты раздаём сами — OBS обращается к одному порту.
|
||||
// В dev клиенты живут на своих vite-портах (5173/5174).
|
||||
const staticMounts = [
|
||||
{ prefix: '/overlay/', dir: path.resolve(__dirname, '../../overlay/dist') },
|
||||
{ prefix: '/web/', dir: path.resolve(__dirname, '../../web/dist') },
|
||||
].map((m) => ({ ...m, middleware: existsSync(m.dir) ? sirv(m.dir) : null }));
|
||||
|
||||
function json(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function handleHttp(req: IncomingMessage, res: ServerResponse): void {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const p = url.pathname;
|
||||
|
||||
if (p === '/healthz') {
|
||||
json(res, 200, {
|
||||
ok: true,
|
||||
protocol: PROTOCOL_VERSION,
|
||||
tickMs: TICK_MS,
|
||||
uptimeSec: Math.round((Date.now() - startedAt) / 1000),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const m of staticMounts) {
|
||||
if (!m.middleware) continue;
|
||||
const base = m.prefix.slice(0, -1);
|
||||
if (p === base) {
|
||||
res.writeHead(301, { location: `${base}/` });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (p.startsWith(m.prefix)) {
|
||||
req.url = p.slice(base.length) + url.search;
|
||||
m.middleware(req, res, () => json(res, 404, { error: 'not found' }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
json(res, 404, {
|
||||
error: 'not found',
|
||||
hint: 'dev: overlay http://localhost:5173, web http://localhost:5174; для /overlay/ и /web/ сначала pnpm build',
|
||||
});
|
||||
}
|
||||
|
||||
const server = createServer(handleHttp);
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const { pathname } = new URL(req.url ?? '/', 'http://localhost');
|
||||
if (pathname === '/ws') {
|
||||
gateway.handleUpgrade(req, socket, head);
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
setInterval(() => gateway.broadcast(snapshot()), TICK_MS);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[idle-xboct] http://localhost:${PORT} — протокол v${PROTOCOL_VERSION}, тик ${TICK_MS} мс`);
|
||||
console.log(`[idle-xboct] overlay: http://localhost:5173 (dev) | web: http://localhost:5174 (dev)`);
|
||||
const built = staticMounts.filter((m) => m.middleware).map((m) => m.prefix);
|
||||
if (built.length > 0) {
|
||||
console.log(`[idle-xboct] раздаю сборки: ${built.join(', ')}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { PROTOCOL_VERSION, type ClientMessage, type ServerMessage } from '@idle/shared';
|
||||
|
||||
declare module 'ws' {
|
||||
interface WebSocket {
|
||||
isAlive?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
interface GatewayOptions {
|
||||
getSnapshot: () => ServerMessage;
|
||||
tickMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Шлюз WS: рукопожатие по версии протокола, welcome + снапшот новому клиенту,
|
||||
* broadcast снапшота каждый тик, heartbeat для мёртвых OBS-сокетов.
|
||||
*/
|
||||
export class WsGateway {
|
||||
private readonly wss = new WebSocketServer({ noServer: true });
|
||||
private readonly clients = new Set<WebSocket>();
|
||||
|
||||
constructor(private readonly opts: GatewayOptions) {
|
||||
this.wss.on('connection', (ws) => this.onConnection(ws));
|
||||
setInterval(() => this.sweep(), 30_000).unref();
|
||||
}
|
||||
|
||||
handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
|
||||
this.wss.handleUpgrade(req, socket, head, (ws) => this.wss.emit('connection', ws, req));
|
||||
}
|
||||
|
||||
broadcast(msg: ServerMessage): void {
|
||||
const payload = JSON.stringify(msg);
|
||||
for (const ws of this.clients) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onConnection(ws: WebSocket): void {
|
||||
ws.isAlive = true;
|
||||
ws.on('pong', () => {
|
||||
ws.isAlive = true;
|
||||
});
|
||||
|
||||
let helloSeen = false;
|
||||
ws.on('message', (raw) => {
|
||||
if (helloSeen) return;
|
||||
helloSeen = true;
|
||||
|
||||
let msg: ClientMessage | null = null;
|
||||
try {
|
||||
msg = JSON.parse(String(raw)) as ClientMessage;
|
||||
} catch {
|
||||
msg = null;
|
||||
}
|
||||
if (!msg || msg.t !== 'hello' || msg.v !== PROTOCOL_VERSION) {
|
||||
ws.close(4001, 'protocol version mismatch');
|
||||
return;
|
||||
}
|
||||
|
||||
this.clients.add(ws);
|
||||
this.send(ws, { t: 'welcome', v: PROTOCOL_VERSION, tickMs: this.opts.tickMs });
|
||||
this.send(ws, this.opts.getSnapshot());
|
||||
});
|
||||
|
||||
ws.on('close', () => this.clients.delete(ws));
|
||||
ws.on('error', () => ws.terminate());
|
||||
}
|
||||
|
||||
private send(ws: WebSocket, msg: ServerMessage): void {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private sweep(): void {
|
||||
for (const ws of this.clients) {
|
||||
if (ws.isAlive === false) {
|
||||
this.clients.delete(ws);
|
||||
ws.terminate();
|
||||
continue;
|
||||
}
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { AvatarState } from '@idle/shared';
|
||||
|
||||
interface Walker {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
y: number;
|
||||
minX: number;
|
||||
maxX: number;
|
||||
/** px/сек */
|
||||
speed: number;
|
||||
/** сдвиг фазы 0..1, чтобы ходоки не шли строем */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
const WALKERS: Walker[] = [
|
||||
{ id: 'stub-1', name: 'XBOCT', color: '#ff8c3b', y: 300, minX: 140, maxX: 820, speed: 90, offset: 0 },
|
||||
{ id: 'stub-2', name: 'pixel_proger', color: '#5bd7ff', y: 360, minX: 220, maxX: 900, speed: 70, offset: 0.35 },
|
||||
{ id: 'stub-3', name: 'viewer_42', color: '#b58cff', y: 240, minX: 80, maxX: 700, speed: 110, offset: 0.7 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Заглушка M0: движение — чистая функция wall-clock.
|
||||
* Сервер мог «не жить» сколько угодно — позиция восстановится из таймстемпа,
|
||||
* как и задумано для ретроактивной симуляции.
|
||||
*/
|
||||
export function stubAvatarsAt(nowMs: number): AvatarState[] {
|
||||
return WALKERS.map((w) => {
|
||||
const length = w.maxX - w.minX;
|
||||
const period = (2 * length) / w.speed;
|
||||
const phase = (((nowMs / 1000) / period) + w.offset) % 1;
|
||||
const leg = phase < 0.5 ? phase * 2 : 2 - phase * 2;
|
||||
return {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
color: w.color,
|
||||
x: Math.round(w.minX + length * leg),
|
||||
y: w.y,
|
||||
face: phase < 0.5 ? 'right' : 'left',
|
||||
moving: true,
|
||||
} satisfies AvatarState;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user