diff --git a/package.json b/package.json index bfa4535..ec46b04 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "scripts": { "start": "bun run --bun src/index.ts", - "build": "bun build src/index.ts --outdir ./build" + "build": "bun build src/index.ts --outdir ./build", + "test": "bun test" } } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index c20c14d..c2dadb4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,77 +1,9 @@ -import {Server, ServerWebSocket} from "bun"; -import {ClientData} from "@/types.ts"; -import {handleHealthEndpoints} from "@/routes/health.ts"; import {Logger} from "@/lib/logger.ts"; -import {ClientPacket, JoinPacket, Packet, QuitPacket, ServerPacket, WelcomePacket} from "@/lib/packet.ts"; import RoomManager from "@/lib/room_manager.ts"; +import startServer from "@/lib/server.ts"; const logger = new Logger(); const roomManager = new RoomManager(4) -const server = Bun.serve({ - port: 3000, - development: false, - fetch(request: Request, server: Server): undefined | Response { - const url = new URL(request.url) - let routes = url.pathname.split("/") - // Rooms will be available via /room/ - if (routes[1].toLowerCase() === 'server') { - - if (routes.length < 3) { - return new Response(JSON.stringify({message: 'Page not found'}), {status: 404}); - } - - if (routes[2].toLowerCase() === 'room') { - if (routes.length < 4) { - return new Response(JSON.stringify({message: "No roomId provided"}), {status: 400}) - } - - let roomId = routes[3].toLowerCase(); - if (!roomId.match(/[a-z\d-]{6,}/)) { - return new Response(JSON.stringify({message: "Invalid roomId provided"}), {status: 400}) - } - - const success = server.upgrade(request, { - data: { - roomId: roomId, - uuid: crypto.randomUUID() - } - }); - return success ? undefined : new Response(JSON.stringify({message: "WebSocket upgrade error"}), {status: 400}); - } - } else if(routes[1].toLowerCase() === 'health') { - return handleHealthEndpoints(routes) - } - return new Response(JSON.stringify({message: 'Page not found'}), {status: 404}); - }, - websocket: { - open(webSocket: ServerWebSocket): void | Promise { - if (roomManager.joinRoom(webSocket.data.roomId, webSocket.data.uuid)) { - logger.info(`${webSocket.data.uuid} connected to room ${webSocket.data.roomId}`) - - server.publish(webSocket.data.roomId, new JoinPacket(webSocket.data.uuid).toString()) - webSocket.subscribe(webSocket.data.roomId) - webSocket.send(new WelcomePacket(webSocket.data.uuid, roomManager.getRoom(webSocket.data.roomId)).toString()) - } else { - webSocket.close(1011, new ServerPacket("LIMIT", webSocket.data.roomId).toString()) - } - }, - message: function (webSocket: ServerWebSocket, message: string): void | Promise { - let packet = JSON.parse(message) as Packet; - if(packet !== undefined && packet.getType().startsWith("CLIENT_")) { - let clientPacket = new ClientPacket(webSocket.data.uuid, packet.getType(), packet.getData()); - webSocket.publish(webSocket.data.roomId, clientPacket.toString()) - } else { - webSocket.send(new ServerPacket("ERROR", {'message': `Invalid packet type: ${packet.getType()}`}).toString()) - } - }, - close(webSocket: ServerWebSocket, code: number, reason: string): void | Promise { - roomManager.leaveRoom(webSocket.data.roomId, webSocket.data.uuid) - logger.info(`${webSocket.data.uuid} disconnected from room ${webSocket.data.roomId}`) - - webSocket.unsubscribe(webSocket.data.roomId) - server.publish(webSocket.data.roomId, new QuitPacket(webSocket.data.uuid).toString()) - } - } -}); +const server = startServer(logger, roomManager) logger.info(`Listening on ${server.hostname}:${server.port}`) \ No newline at end of file diff --git a/src/lib/packet.ts b/src/lib/packet.ts index 4427092..b349efc 100644 --- a/src/lib/packet.ts +++ b/src/lib/packet.ts @@ -1,38 +1,22 @@ export class Packet { - private readonly type: string; - private readonly data: unknown; + public readonly type: string; + public readonly data: unknown; constructor(type: string, data: unknown) { this.type = type; this.data = data; } - - getType() : string { - return this.type - } - - getData() : unknown { - return this.data - } - - toString() : string { - return JSON.stringify({ - 'type': this.type, - 'data': this.data - }) - } } export class ServerPacket extends Packet { constructor(type: string, data: unknown) { super("ROOM_" + type, data); } - } export class ClientPacket extends Packet { - private readonly sender: string; + public readonly sender: string; constructor(sender: string, type: string, data: unknown) { super(type, data); diff --git a/src/lib/server.ts b/src/lib/server.ts new file mode 100644 index 0000000..198baff --- /dev/null +++ b/src/lib/server.ts @@ -0,0 +1,76 @@ +import RoomManager from "@/lib/room_manager.ts"; +import {ClientData} from "@/types.ts"; +import {Server, ServerWebSocket} from "bun"; +import {handleHealthEndpoints} from "@/routes/health.ts"; +import {ClientPacket, JoinPacket, Packet, QuitPacket, ServerPacket, WelcomePacket} from "@/lib/packet.ts"; +import {Logger} from "@/lib/logger.ts"; + +export default function startServer(logger: Logger, roomManager: RoomManager, port: number = 3000) : Server { + const server = Bun.serve({ + port: port, + development: false, + fetch(request: Request, server: Server): undefined | Response { + const url = new URL(request.url) + let routes = url.pathname.split("/") + // Rooms will be available via /room/ + if (routes[1].toLowerCase() === 'server') { + + if (routes.length < 3) { + return new Response(JSON.stringify({message: 'Page not found'}), {status: 404}); + } + + if (routes[2].toLowerCase() === 'room') { + if (routes.length < 4) { + return new Response(JSON.stringify({message: "No roomId provided"}), {status: 400}) + } + + let roomId = routes[3].toLowerCase(); + if (!roomId.match(/[a-z\d-]{6,}/)) { + return new Response(JSON.stringify({message: "Invalid roomId provided"}), {status: 400}) + } + + const success = server.upgrade(request, { + data: { + roomId: roomId, + uuid: crypto.randomUUID() + } + }); + return success ? undefined : new Response(JSON.stringify({message: "WebSocket upgrade error"}), {status: 400}); + } + } else if(routes[1].toLowerCase() === 'health') { + return handleHealthEndpoints(routes) + } + return new Response(JSON.stringify({message: 'Page not found'}), {status: 404}); + }, + websocket: { + open(webSocket: ServerWebSocket): void | Promise { + if (roomManager.joinRoom(webSocket.data.roomId, webSocket.data.uuid)) { + logger.info(`${webSocket.data.uuid} connected to room ${webSocket.data.roomId}`) + + server.publish(webSocket.data.roomId, JSON.stringify(new JoinPacket(webSocket.data.uuid))) + webSocket.subscribe(webSocket.data.roomId) + webSocket.sendText(JSON.stringify(new WelcomePacket(webSocket.data.uuid, roomManager.getRoom(webSocket.data.roomId))), true) + } else { + webSocket.close(1011, JSON.stringify(new ServerPacket("LIMIT", webSocket.data.roomId))) + } + }, + message: function (webSocket: ServerWebSocket, message: string): void | Promise { + let packet = JSON.parse(message) as Packet; + if(packet !== undefined && packet.type.startsWith("CLIENT_")) { + let clientPacket = new ClientPacket(webSocket.data.uuid, packet.type, packet.data); + webSocket.publish(webSocket.data.roomId, JSON.stringify(clientPacket)) + } else { + webSocket.send(JSON.stringify(new ServerPacket("ERROR", {'message': `Invalid packet type: ${packet.type}`}))) + } + }, + close(webSocket: ServerWebSocket, code: number, reason: string): void | Promise { + roomManager.leaveRoom(webSocket.data.roomId, webSocket.data.uuid) + logger.info(`${webSocket.data.uuid} disconnected from room ${webSocket.data.roomId}`) + + webSocket.unsubscribe(webSocket.data.roomId) + server.publish(webSocket.data.roomId, JSON.stringify(new QuitPacket(webSocket.data.uuid))) + } + } + }); + return server; +} \ No newline at end of file diff --git a/tests/health.test.ts b/tests/health.test.ts index 85ab445..6529020 100644 --- a/tests/health.test.ts +++ b/tests/health.test.ts @@ -1,5 +1,6 @@ import {describe, expect, test} from "bun:test"; import {handleHealthEndpoints} from "@/routes/health.ts"; + describe("health-endpoints", () => { test("invalid", () => { expect(handleHealthEndpoints(["bla", "health", "invalid"])?.status).toBe(404) diff --git a/tests/join.test.ts b/tests/join.test.ts new file mode 100644 index 0000000..8bd2ae1 --- /dev/null +++ b/tests/join.test.ts @@ -0,0 +1,73 @@ +import {afterAll, beforeAll, beforeEach, describe, expect, test} from "bun:test"; +import {Logger} from "@/lib/logger.ts"; +import RoomManager from "@/lib/room_manager.ts"; +import startServer from "@/lib/server.ts"; +import {Server} from "bun"; +import {Packet} from "@/lib/packet.ts"; + +describe('join', () => { + + let server: Server; + + beforeAll(() => { + const logger = new Logger(); + const roomManager = new RoomManager(4); + server = startServer(logger, roomManager, 3000) + }) + + afterAll(() => { + server.stop(true); + }) + + test('Server allows connection to room', async () => { + const clientOne: WebSocket = new WebSocket("ws://localhost:3000/server/room/abc-123") + + let lastPacket = new Packet('ERROR', 'none') + clientOne.addEventListener("message", function (event) { + lastPacket = JSON.parse(event.data) as Packet; + clientOne.terminate() + }) + + let error: number = 0; + clientOne.addEventListener("error", event => { + error += 1; + }) + + await waitForSocketState(clientOne, WebSocket.CLOSED); + + expect(lastPacket.type).toBe("ROOM_WELCOME"); + expect(error).toBe(0); + }) + + test('Join packet is broadcasted', async () => { + const clientOne: WebSocket = new WebSocket("ws://localhost:3000/server/room/abc-123") + + let lastPacket= new Packet('ERROR', 'none') + clientOne.addEventListener("message", function (event) { + lastPacket = JSON.parse(event.data) as Packet; + }) + + const clientTwo: WebSocket = new WebSocket("ws://localhost:3000/server/room/abc-123") + await waitForSocketState(clientTwo, WebSocket.OPEN) + + clientOne.close() + clientTwo.close() + + await waitForSocketState(clientOne, WebSocket.CLOSED); + await waitForSocketState(clientTwo, WebSocket.CLOSED); + + expect(lastPacket.type).toBe("ROOM_JOIN") + }) +}) + +function waitForSocketState(socket: WebSocket, state: WebSocketReadyState) { + return new Promise(function (resolve) { + setTimeout(function () { + if (socket.readyState === state) { + resolve(); + } else { + waitForSocketState(socket, state).then(resolve); + } + }, 5); + }); +} \ No newline at end of file