Skip to content

Commit dd372b2

Browse files
Gary YeungGary Yeung
Gary Yeung
authored and
Gary Yeung
committedDec 18, 2024
done
1 parent 822dd5b commit dd372b2

10 files changed

+546
-1
lines changed
 

‎.gitignore

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Node modules
2+
node_modules/
3+
4+
# Developing Things
5+
GOAL.md
6+
7+
# package
8+
package-lock.json
9+
10+
# vim
11+
*~
12+
13+
# my build
14+
dist/
15+
16+
17+

‎LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 Gary Yeung
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎README.md

+54-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,54 @@
1-
# broadcast_server
1+
# Broadcast Server
2+
(https://roadmap.sh/projects/broadcast-server)
3+
A real-time broadcast server implementation using WebSocket protocol.
4+
This serveer supports user authentication, real-time messaging, and user status tracking.
5+
6+
## Features
7+
- Real-time WebSocket communication
8+
- Username-based authenticaiton
9+
- User presence trakcing (join/leave notifications)
10+
- Broadcast messaging system
11+
- Connection state mamagement
12+
- Error handling and connection recovery
13+
- TypeScript support
14+
15+
## Prerequisites
16+
- Node.js (latest)
17+
- npm or yarn
18+
- TypeScript
19+
20+
## Installation
21+
```sh
22+
git clone https://github.com/garyeung/broadcast_server.git
23+
24+
cd broadcast_server
25+
26+
npm install
27+
```
28+
29+
## Usages
30+
```bash
31+
# development
32+
$ npm run dev
33+
34+
# build
35+
$ npm run build
36+
37+
# start
38+
$ npm run broadcast-server
39+
40+
```
41+
42+
## Project Structure
43+
```stylus
44+
├── src/
45+
│ ├── cli.ts # Commander implementation
46+
│ ├── client.ts # Client server implementation
47+
│ ├── broadcastServer.ts # Broadcast server implementation
48+
│ ├── config.ts # Configuration and types
49+
│ └── index.ts # Entry point
50+
51+
├── package.json
52+
├── tsconfig.json
53+
└── README.md
54+
```stylus

‎package.json

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "broadcast_server",
3+
"version": "1.0.0",
4+
"description": "",
5+
"main": "index.js",
6+
"scripts": {
7+
"build": "tsc",
8+
"broadcast-server": "npm run build && node dist/index.js --",
9+
"dev": "ts-node src/index.ts --"
10+
},
11+
"keywords": [],
12+
"author": "",
13+
"license": "ISC",
14+
"dependencies": {
15+
"commander": "^12.1.0",
16+
"ws": "^8.18.0"
17+
},
18+
"devDependencies": {
19+
"@types/node": "^22.10.2",
20+
"@types/ws": "^8.5.13",
21+
"ts-node": "^10.9.2",
22+
"typescript": "^5.7.2"
23+
},
24+
"rootDir": "src"
25+
}

‎src/broadcastServer.ts

+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import WebSocket from "ws";
2+
import { createServer, IncomingMessage, Server } from "http";
3+
import { AutenticateClient, Message, PROTOCOL } from "./config";
4+
import { Socket } from "net";
5+
6+
export class BroadcastServer {
7+
private wss: WebSocket.Server;
8+
private hs: Server;
9+
private clients: Map<WebSocket, string> = new Map();
10+
protected readonly PROTOCOL = PROTOCOL;
11+
12+
constructor(port: number){
13+
this.hs = createServer();
14+
this.wss = new WebSocket.Server({noServer:true});
15+
16+
// Bind methods to preserve 'this' context
17+
this.onHttpUpgrade = this.onHttpUpgrade.bind(this);
18+
this.onSocketError = this.onSocketError.bind(this);
19+
this.authenticate = this.authenticate.bind(this);
20+
21+
this.init(port);
22+
}
23+
24+
private init(port: number) {
25+
this.hs.on('upgrade', this.onHttpUpgrade);
26+
this.onWebsocketConncetion();
27+
28+
this.hs.listen(port, () => {
29+
console.log(`Broadcast Server is running on port ${port}...`)
30+
});
31+
}
32+
33+
private onHttpUpgrade(req: IncomingMessage, socket: Socket, header: Buffer){
34+
socket.on('error', this.onSocketError);
35+
36+
this.authenticate(req, (err, client) => {
37+
if(err || !client) {
38+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
39+
socket.destroy();
40+
return;
41+
}
42+
socket.removeListener('error', this.onSocketError);
43+
44+
this.wss.handleUpgrade(req, socket, header, (ws) => {
45+
this.wss.emit("connection", ws, req, client);
46+
});
47+
});
48+
}
49+
50+
private onSocketError(error: Error){
51+
console.log(`Socket Error: `, error.message);
52+
}
53+
54+
private authenticate(req: IncomingMessage, callback: (err: Error | null, client?: AutenticateClient) => void){
55+
const protocols = req.headers['sec-websocket-protocol'];
56+
const username = req.headers['x-username'];
57+
58+
if(!protocols || !protocols.split(',').map(p => p.trim()).includes(this.PROTOCOL)){
59+
callback(new Error('Invalid protocol'));
60+
return;
61+
}
62+
63+
if(!username || typeof username !== 'string' || username.trim() === ''){
64+
callback(new Error("username is required"));
65+
return;
66+
}
67+
68+
let candidateUsername = username.trim();
69+
70+
const foundInClient = Array.from(this.clients.values())
71+
.some((name) => name.toLowerCase() === candidateUsername.toLowerCase());
72+
73+
if(foundInClient){
74+
callback(new Error('username already taken'));
75+
return;
76+
}
77+
78+
callback(null, {username: candidateUsername});
79+
}
80+
81+
private onWebsocketConncetion() {
82+
this.wss.on('connection', (ws: WebSocket, req: IncomingMessage, client: AutenticateClient) => {
83+
const username = client.username;
84+
this.clients.set(ws, username);
85+
86+
console.log(`${username} has joined the chat`);
87+
console.log(this.onlineUsers());
88+
89+
this.broadcast({
90+
type: 'system',
91+
payload: this.onlineUsers()
92+
});
93+
94+
const findClient = () => {
95+
return Array.from(this.clients.entries())
96+
.find(([client]) => client === ws)?.[1];
97+
};
98+
99+
ws.on('message', (message) => {
100+
const found = findClient();
101+
if(found){
102+
console.log(`${found}: ${message.toString()}`);
103+
this.broadcast({
104+
type: 'message',
105+
payload: message.toString(),
106+
username: found
107+
}, ws);
108+
}
109+
});
110+
111+
ws.on('close', () => {
112+
const username = findClient();
113+
if (username) {
114+
console.log(`${username} has left the chat`);
115+
this.broadcast({
116+
type: 'system',
117+
payload: this.onlineUsers()
118+
});
119+
this.deleteClient(ws);
120+
}
121+
});
122+
123+
ws.on('error', (err) => {
124+
console.log("Client Error: ", err.message);
125+
this.deleteClient(ws);
126+
});
127+
});
128+
}
129+
130+
private broadcast(message: Message, sender?: WebSocket){
131+
const toBroadcast = message.type === 'system'
132+
? `System: ${message.payload}`
133+
: `${message.username}: ${message.payload}`;
134+
135+
this.clients.forEach((name, client) => {
136+
if(client !== sender && client.readyState === WebSocket.OPEN){
137+
client.send(toBroadcast);
138+
}
139+
});
140+
}
141+
142+
private onlineUsers(){
143+
return `Current online users: ${this.clients.size}`;
144+
}
145+
146+
private deleteClient(ws: WebSocket){
147+
this.clients.delete(ws);
148+
console.log(this.onlineUsers());
149+
}
150+
}

‎src/cli.ts

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Command } from "commander";
2+
import { BroadcastServer } from "./broadcastServer";
3+
import { BroadcastClient } from "./client";
4+
5+
6+
const DEFAULT_PORT = 8080;
7+
const DEFAULT_URL = `ws://localhost:${DEFAULT_PORT}`;
8+
9+
const program = new Command();
10+
11+
program
12+
.name('broadcast-server')
13+
.description('A simple broadcast server and client implementation')
14+
.version('0.1.0');
15+
16+
program
17+
.command('start')
18+
.description('Start the broadcast server')
19+
.option('-p, --port <number>', 'port to listen on', DEFAULT_PORT.toString())
20+
.action((option) => {
21+
const port = parseInt(option.port);
22+
new BroadcastServer(port);
23+
});
24+
25+
program
26+
.command('connect')
27+
.description('Connect to the broadcast server')
28+
.option('-u, --url <string>', 'server URL to connect to', DEFAULT_URL)
29+
.action((option) => {
30+
new BroadcastClient(option.url);
31+
});
32+
33+
program.parse();
34+
35+
export {};

‎src/client.ts

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// broadcast-client.ts
2+
import WebSocket from 'ws';
3+
import readline from 'readline';
4+
import { PROTOCOL } from './config';
5+
6+
export class BroadcastClient {
7+
private ws: WebSocket | null = null;
8+
private rl: readline.Interface;
9+
private username: string = '';
10+
private serverUrl: string;
11+
private readonly PROTOCOL = PROTOCOL;
12+
13+
constructor(serverUrl: string) {
14+
this.serverUrl = serverUrl;
15+
this.rl = readline.createInterface({
16+
input: process.stdin,
17+
output: process.stdout
18+
});
19+
this.init();
20+
}
21+
22+
private init(): void {
23+
this.authenticate();
24+
}
25+
26+
private authenticate(): void {
27+
this.rl.question('Enter your username: ', (username) => {
28+
if (!username.trim()) {
29+
console.error('\nUsername cannot be empty');
30+
this.authenticate();
31+
return;
32+
}
33+
34+
this.username = username.trim();
35+
this.connectToServer();
36+
});
37+
}
38+
39+
private connectToServer(): void {
40+
try {
41+
const options = {
42+
headers: {
43+
'X-Username': this.username
44+
},
45+
};
46+
47+
this.ws = new WebSocket(this.serverUrl, this.PROTOCOL, options);
48+
49+
this.ws.on('open', () => {
50+
console.log('Connected to server');
51+
this.typing();
52+
});
53+
54+
this.ws.on('error', (error) => {
55+
console.error('\nConnection failed:', error.message);
56+
this.cleanup();
57+
});
58+
59+
this.ws.on('close', (code, reason) => {
60+
console.log('\nDisconnected from server:', reason.toString());
61+
this.cleanup();
62+
});
63+
64+
this.ws.on('message', (data) => {
65+
try {
66+
// Clear the current "Typing:" prompt
67+
process.stdout.write('\r'); // Move cursor back to the beginning of the line
68+
process.stdout.write(' '.repeat(20)); // Clear the line
69+
process.stdout.write('\r'); // Move cursor back to the beginning
70+
71+
// Display the received message
72+
console.log(`${data.toString()}`);
73+
74+
// Reprint the "Typing:" prompt
75+
this.rl.prompt(true);
76+
} catch (error) {
77+
console.error('\nMessage Error:', error);
78+
}
79+
});
80+
81+
} catch (error) {
82+
console.error('Failed to connect:', error);
83+
process.exit(1);
84+
}
85+
}
86+
87+
private typing(): void {
88+
if (!this.ws) return;
89+
90+
this.rl.question('Typing: ', (message) => {
91+
const canbeSent = message.trim().toLowerCase();
92+
if (canbeSent === 'quit' || canbeSent === "exit") {
93+
this.rl.close();
94+
this.ws!.close();
95+
return;
96+
}
97+
98+
if (canbeSent) {
99+
this.ws!.send(message);
100+
}
101+
102+
this.typing();
103+
});
104+
}
105+
106+
private cleanup(){
107+
if(this.ws){
108+
this.ws.close();
109+
this.ws = null;
110+
}
111+
this.rl.close();
112+
process.exit(0);
113+
}
114+
}

‎src/config.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export const PROTOCOL = 'chat';
2+
3+
export type ServerMessage = {
4+
type:'system';
5+
payload: string;
6+
}
7+
8+
export type ClientMessage = {
9+
type:'message';
10+
username: string;
11+
payload: string;
12+
}
13+
14+
export type Message = ClientMessage | ServerMessage;
15+
16+
export interface AutenticateClient {
17+
username: string;
18+
}

‎src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import './cli';

‎tsconfig.json

+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
{
2+
"compilerOptions": {
3+
/* Visit https://aka.ms/tsconfig to read more about this file */
4+
5+
/* Projects */
6+
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
7+
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8+
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
9+
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
10+
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11+
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12+
13+
/* Language and Environment */
14+
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
15+
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
16+
// "jsx": "preserve", /* Specify what JSX code is generated. */
17+
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
18+
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
19+
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
20+
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
21+
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
22+
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
23+
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
24+
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
25+
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
26+
27+
/* Modules */
28+
"module": "commonjs", /* Specify what module code is generated. */
29+
// "rootDir": "./", /* Specify the root folder within your source files. */
30+
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
31+
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
32+
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
33+
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
34+
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
35+
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
36+
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
37+
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
38+
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
39+
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
40+
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
41+
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
42+
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
43+
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
44+
// "resolveJsonModule": true, /* Enable importing .json files. */
45+
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
46+
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
47+
48+
/* JavaScript Support */
49+
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
50+
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
51+
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
52+
53+
/* Emit */
54+
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
55+
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
56+
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
57+
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
58+
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
59+
// "noEmit": true, /* Disable emitting files from a compilation. */
60+
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
61+
"outDir": "./dist", /* Specify an output folder for all emitted files. */
62+
"removeComments": true, /* Disable emitting comments. */
63+
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
64+
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
65+
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
66+
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
67+
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
68+
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
69+
// "newLine": "crlf", /* Set the newline character for emitting files. */
70+
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
71+
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
72+
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
73+
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
74+
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
75+
76+
/* Interop Constraints */
77+
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
78+
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
79+
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
80+
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
81+
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
82+
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
83+
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
84+
85+
/* Type Checking */
86+
"strict": true, /* Enable all strict type-checking options. */
87+
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
88+
"strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
89+
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
90+
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
91+
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
92+
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
93+
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
94+
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
95+
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
96+
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
97+
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
98+
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
99+
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
100+
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
101+
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
102+
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
103+
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
104+
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
105+
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
106+
107+
/* Completeness */
108+
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
109+
"skipLibCheck": true /* Skip type checking all .d.ts files. */
110+
}
111+
}

0 commit comments

Comments
 (0)
Please sign in to comment.