hacker-lobby 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+
3
+ import readline from 'readline';
4
+ import {
5
+ clearScreen,
6
+ drawBanner,
7
+ formatMessage,
8
+ formatSystem,
9
+ formatError,
10
+ COLORS,
11
+ setScrollRegion,
12
+ resetScrollRegion,
13
+ moveCursor,
14
+ saveCursor,
15
+ restoreCursor,
16
+ clearCurrentLine
17
+ } from './src/ui.js';
18
+ import { setAlias, getAlias } from './src/config.js';
19
+ import { connectToStream, sendMessage } from './src/api.js';
20
+
21
+ let abortController = null;
22
+
23
+ // Setup readline interface
24
+ const rl = readline.createInterface({
25
+ input: process.stdin,
26
+ output: process.stdout,
27
+ });
28
+
29
+ const messages = [];
30
+ let chatActive = false;
31
+ let muteNewline = false;
32
+
33
+ // Override stdout.write to intercept the readline newline on enter keypress.
34
+ // This prevents the entire terminal window from scrolling up when the user submits a message.
35
+ const originalWrite = process.stdout.write.bind(process.stdout);
36
+ process.stdout.write = (chunk, encoding, callback) => {
37
+ const data = chunk.toString();
38
+ if (muteNewline && (data === '\n' || data === '\r\n' || data === '\r')) {
39
+ return true;
40
+ }
41
+ return originalWrite(chunk, encoding, callback);
42
+ };
43
+
44
+ // Monitor stdin keypresses to catch Enter and mute the corresponding newline.
45
+ process.stdin.on('keypress', (char, key) => {
46
+ if (key && (key.name === 'return' || key.name === 'enter')) {
47
+ muteNewline = true;
48
+ }
49
+ });
50
+
51
+ function promptAlias() {
52
+ clearScreen();
53
+ drawBanner();
54
+
55
+ process.stdout.write(`${COLORS.YELLOW}${COLORS.BOLD}Choose an alias: ${COLORS.RESET}`);
56
+
57
+ rl.question('', (input) => {
58
+ const alias = input.trim();
59
+ if (!alias) {
60
+ process.stdout.write('\n' + formatError('Alias cannot be empty. Please try again.') + '\n');
61
+ setTimeout(promptAlias, 1500);
62
+ return;
63
+ }
64
+
65
+ setAlias(alias);
66
+ initChat();
67
+ });
68
+ }
69
+
70
+ function initChat() {
71
+ chatActive = true;
72
+
73
+ // Set up initial layout and scroll regions
74
+ drawLayout();
75
+
76
+ // Add welcome system messages
77
+ addSystemMessage(`Welcome @${getAlias()} to the HACKER LOBBY!`);
78
+ addSystemMessage(`Type your message and press Enter. Type "/exit" to leave.`);
79
+
80
+ // Set up prompt
81
+ const promptStr = `${COLORS.CYAN}${COLORS.BOLD}[${getAlias()}]: ${COLORS.RESET}`;
82
+ rl.setPrompt(promptStr);
83
+
84
+ // Connect to backend Server-Sent Events stream
85
+ abortController = new AbortController();
86
+ connectToStream((message) => {
87
+ addMessage(message.username, message.content);
88
+ }, abortController.signal).catch((err) => {
89
+ addSystemMessage(`Stream disconnected: ${err.message}`);
90
+ });
91
+
92
+ rl.on('line', (line) => {
93
+ // Disable newline muting once readline has finished processing the line
94
+ muteNewline = false;
95
+
96
+ const text = line.trim();
97
+ if (text) {
98
+ if (text === '/exit' || text === '/quit') {
99
+ cleanupAndExit();
100
+ }
101
+
102
+ // Post the message to the Edge server
103
+ sendMessage(getAlias(), text).catch((err) => {
104
+ addSystemMessage(`Failed to send message: ${err.message}`);
105
+ });
106
+ }
107
+
108
+ // Redraw prompt at the bottom
109
+ const rows = process.stdout.rows || 24;
110
+ moveCursor(rows, 1);
111
+ clearCurrentLine();
112
+ rl.prompt(true);
113
+ });
114
+
115
+ // Handle window resizing dynamically
116
+ process.stdout.on('resize', () => {
117
+ if (chatActive) {
118
+ drawLayout();
119
+ }
120
+ });
121
+
122
+ // Initial prompt display
123
+ rl.prompt(true);
124
+ }
125
+
126
+ function drawLayout() {
127
+ const rows = process.stdout.rows || 24;
128
+ const cols = process.stdout.columns || 80;
129
+
130
+ // Clear screen completely
131
+ clearScreen();
132
+
133
+ // 1. Draw static header banner if screen has enough space
134
+ const hasBanner = rows > 16;
135
+ let topMargin = 1;
136
+
137
+ if (hasBanner) {
138
+ drawBanner();
139
+ topMargin = 11; // Banner + borders take 10 rows
140
+ }
141
+
142
+ const bottomMargin = rows - 2;
143
+
144
+ // 2. Draw static divider line just above the input prompt
145
+ moveCursor(rows - 1, 1);
146
+ process.stdout.write(COLORS.GRAY + '─'.repeat(cols) + COLORS.RESET);
147
+
148
+ // 3. Set the scrolling region for messages
149
+ setScrollRegion(topMargin, bottomMargin);
150
+
151
+ // 4. Fill the scrolling region with message history
152
+ const maxMessages = bottomMargin - topMargin + 1;
153
+ const history = messages.slice(-maxMessages);
154
+
155
+ for (let i = 0; i < history.length; i++) {
156
+ moveCursor(topMargin + i, 1);
157
+ process.stdout.write(history[i]);
158
+ }
159
+
160
+ // Clear remaining lines in scroll region if history is short
161
+ for (let i = history.length; i < maxMessages; i++) {
162
+ moveCursor(topMargin + i, 1);
163
+ clearCurrentLine();
164
+ }
165
+
166
+ // 5. Position cursor on the bottom row for typing
167
+ moveCursor(rows, 1);
168
+ clearCurrentLine();
169
+
170
+ // 6. Display prompt
171
+ const promptStr = `${COLORS.CYAN}${COLORS.BOLD}[${getAlias()}]: ${COLORS.RESET}`;
172
+ rl.setPrompt(promptStr);
173
+ rl.prompt(true);
174
+ }
175
+
176
+ function addMessage(sender, text) {
177
+ const formatted = formatMessage(sender, text);
178
+ messages.push(formatted);
179
+
180
+ const rows = process.stdout.rows || 24;
181
+ const bottomMargin = rows - 2;
182
+
183
+ // Move to bottom of scroll area
184
+ moveCursor(bottomMargin, 1);
185
+ process.stdout.write(formatted + '\n');
186
+
187
+ // Restore cursor to prompt line
188
+ const promptLength = getAlias().length + 4;
189
+ const col = promptLength + (rl.cursor || 0) + 1;
190
+ moveCursor(rows, col);
191
+ }
192
+
193
+ function addSystemMessage(text) {
194
+ const formatted = formatSystem(text);
195
+ messages.push(formatted);
196
+
197
+ const rows = process.stdout.rows || 24;
198
+ const bottomMargin = rows - 2;
199
+
200
+ // Move to bottom of scroll area
201
+ moveCursor(bottomMargin, 1);
202
+ process.stdout.write(formatted + '\n');
203
+
204
+ // Restore cursor to prompt line
205
+ const promptLength = getAlias().length + 4;
206
+ const col = promptLength + (rl.cursor || 0) + 1;
207
+ moveCursor(rows, col);
208
+ }
209
+
210
+ function cleanupAndExit() {
211
+ if (abortController) {
212
+ abortController.abort();
213
+ }
214
+ resetScrollRegion();
215
+ clearScreen();
216
+ console.log(formatSystem('Goodbye!'));
217
+ process.exit(0);
218
+ }
219
+
220
+ // Start the entry point sequence
221
+ promptAlias();
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "hacker-lobby",
3
+ "version": "1.0.0",
4
+ "description": "Multiplayer terminal chat application",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "hacker-lobby": "./index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node index.js"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "src"
16
+ ],
17
+ "engines": {
18
+ "node": ">=16.0.0"
19
+ }
20
+ }
package/src/api.js ADDED
@@ -0,0 +1,76 @@
1
+ const API_URL = process.env.API_URL || 'https://hacker-lobby-backend.spidozx.workers.dev';
2
+
3
+ /**
4
+ * Connects to the SSE stream at /listen and parses incoming messages in real-time.
5
+ * @param {Function} onMessageCallback - Invoked with parsed message JSON payload.
6
+ * @param {AbortSignal} [signal] - Optional signal to abort/disconnect the connection.
7
+ */
8
+ export async function connectToStream(onMessageCallback, signal) {
9
+ const url = `${API_URL}/listen`;
10
+
11
+ try {
12
+ const response = await fetch(url, { signal });
13
+
14
+ if (!response.ok) {
15
+ throw new Error(`Failed to connect to stream: ${response.statusText}`);
16
+ }
17
+
18
+ if (!response.body) {
19
+ throw new Error('ReadableStream not supported or empty body');
20
+ }
21
+
22
+ const decoder = new TextDecoder();
23
+ let buffer = '';
24
+
25
+ for await (const chunk of response.body) {
26
+ buffer += decoder.decode(chunk, { stream: true });
27
+ const lines = buffer.split('\n');
28
+
29
+ // The last line may be incomplete, hold it in the buffer
30
+ buffer = lines.pop() || '';
31
+
32
+ for (const line of lines) {
33
+ const trimmed = line.trim();
34
+ if (trimmed.startsWith('data: ')) {
35
+ try {
36
+ const data = JSON.parse(trimmed.slice(6));
37
+ onMessageCallback(data);
38
+ } catch (e) {
39
+ // Ignore malformed JSON or SSE control lines
40
+ }
41
+ }
42
+ }
43
+ }
44
+ } catch (err) {
45
+ // Suppress error if aborted intentionally
46
+ if (err.name === 'AbortError' || (signal && signal.aborted)) {
47
+ return;
48
+ }
49
+ throw err;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Sends a message to the edge server POST /say endpoint.
55
+ * @param {string} user - Username of the sender.
56
+ * @param {string} text - Message text content.
57
+ * @returns {Promise<Object>} Response JSON.
58
+ */
59
+ export async function sendMessage(user, text) {
60
+ const url = `${API_URL}/say`;
61
+
62
+ const response = await fetch(url, {
63
+ method: 'POST',
64
+ headers: {
65
+ 'Content-Type': 'application/json',
66
+ },
67
+ body: JSON.stringify({ user, text }),
68
+ });
69
+
70
+ if (!response.ok) {
71
+ const errData = await response.json().catch(() => ({}));
72
+ throw new Error(errData.error || `HTTP error! Status: ${response.status}`);
73
+ }
74
+
75
+ return response.json();
76
+ }
package/src/config.js ADDED
@@ -0,0 +1,19 @@
1
+ export const state = {
2
+ alias: '',
3
+ };
4
+
5
+ /**
6
+ * Set the user's chosen alias
7
+ * @param {string} alias
8
+ */
9
+ export function setAlias(alias) {
10
+ state.alias = alias.trim();
11
+ }
12
+
13
+ /**
14
+ * Get the user's current alias
15
+ * @returns {string}
16
+ */
17
+ export function getAlias() {
18
+ return state.alias;
19
+ }
package/src/ui.js ADDED
@@ -0,0 +1,122 @@
1
+ export const COLORS = {
2
+ RESET: '\x1b[0m',
3
+ BOLD: '\x1b[1m',
4
+ DIM: '\x1b[2m',
5
+ ITALIC: '\x1b[3m',
6
+ UNDERLINE: '\x1b[4m',
7
+
8
+ // Foreground Colors
9
+ RED: '\x1b[31m',
10
+ GREEN: '\x1b[32m',
11
+ YELLOW: '\x1b[33m',
12
+ BLUE: '\x1b[34m',
13
+ MAGENTA: '\x1b[35m',
14
+ CYAN: '\x1b[36m',
15
+ WHITE: '\x1b[37m',
16
+ GRAY: '\x1b[90m',
17
+
18
+ // Custom theme colors
19
+ NEON_GREEN: '\x1b[38;5;82m',
20
+ NEON_CYAN: '\x1b[38;5;87m',
21
+ DARK_GRAY: '\x1b[38;5;240m',
22
+ };
23
+
24
+ /**
25
+ * Clear the entire terminal screen and scrollback buffer,
26
+ * then place cursor at top-left.
27
+ */
28
+ export function clearScreen() {
29
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
30
+ }
31
+
32
+ /**
33
+ * Render the high-impact hacker lobby ASCII banner
34
+ */
35
+ export function drawBanner() {
36
+ const banner = `
37
+ ${COLORS.CYAN} ██╗ ██╗ █████╗ ██████╗██╗ ██╗███████╗██████╗ ██╗ ██████╗ ██████╗ ██████╗ ██╗ ██╗
38
+ ██║ ██║██╔══██╗██╔════╝██║ ██╔╝██╔════╝██╔══██╗ ██║ ██╔═══██╗██╔══██╗██╔══██╗╚██╗ ██╔╝
39
+ ███████║███████║██║ █████╔╝ █████╗ ██████╔╝ ██║ ██║ ██║██████╔╝██████╔╝ ╚████╔╝
40
+ ██╔══██║██╔══██║██║ ██╔═██╗ ██╔══╝ ██╔══██╗ ██║ ██║ ██║██╔══██╗██╔══██╗ ╚██╔╝
41
+ ██║ ██║██║ ██║╚██████╗██║ ██╗███████╗██║ ██║ ███████╗╚██████╔╝██████╔╝██████╔╝ ██║
42
+ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚══════╝ ╚══════╝╚══════╝╚══════╝ ╚═╝${COLORS.RESET}
43
+ `;
44
+ process.stdout.write(banner);
45
+ process.stdout.write(`\n${COLORS.GRAY}===========================================================================================${COLORS.RESET}\n`);
46
+ process.stdout.write(`${COLORS.NEON_GREEN}${COLORS.BOLD} [SECURE MULTIPLAYER CHAT LOBBY] — TYPE /exit TO QUIT${COLORS.RESET}\n`);
47
+ process.stdout.write(`${COLORS.GRAY}===========================================================================================${COLORS.RESET}\n\n`);
48
+ }
49
+
50
+ /**
51
+ * Formats user chat message
52
+ * @param {string} sender
53
+ * @param {string} text
54
+ * @returns {string}
55
+ */
56
+ export function formatMessage(sender, text) {
57
+ return `${COLORS.CYAN}${COLORS.BOLD}${sender}${COLORS.RESET}: ${text}`;
58
+ }
59
+
60
+ /**
61
+ * Formats a system status message
62
+ * @param {string} text
63
+ * @returns {string}
64
+ */
65
+ export function formatSystem(text) {
66
+ return `${COLORS.GREEN}${COLORS.BOLD}[SYSTEM]${COLORS.RESET} ${COLORS.NEON_GREEN}${text}${COLORS.RESET}`;
67
+ }
68
+
69
+ /**
70
+ * Formats an error notification
71
+ * @param {string} text
72
+ * @returns {string}
73
+ */
74
+ export function formatError(text) {
75
+ return `${COLORS.RED}${COLORS.BOLD}[ERROR]${COLORS.RESET} ${COLORS.RED}${text}${COLORS.RESET}`;
76
+ }
77
+
78
+ /**
79
+ * Set terminal scrolling region (rows 1-indexed)
80
+ * @param {number} top
81
+ * @param {number} bottom
82
+ */
83
+ export function setScrollRegion(top, bottom) {
84
+ process.stdout.write(`\x1b[${top};${bottom}r`);
85
+ }
86
+
87
+ /**
88
+ * Reset terminal scrolling region to full window
89
+ */
90
+ export function resetScrollRegion() {
91
+ process.stdout.write('\x1b[r');
92
+ }
93
+
94
+ /**
95
+ * Move cursor to a specific row and column
96
+ * @param {number} row
97
+ * @param {number} col
98
+ */
99
+ export function moveCursor(row, col) {
100
+ process.stdout.write(`\x1b[${row};${col}H`);
101
+ }
102
+
103
+ /**
104
+ * Save current cursor position
105
+ */
106
+ export function saveCursor() {
107
+ process.stdout.write('\x1b[s');
108
+ }
109
+
110
+ /**
111
+ * Restore previously saved cursor position
112
+ */
113
+ export function restoreCursor() {
114
+ process.stdout.write('\x1b[u');
115
+ }
116
+
117
+ /**
118
+ * Clear the current cursor line
119
+ */
120
+ export function clearCurrentLine() {
121
+ process.stdout.write('\x1b[2K');
122
+ }
package/src/worker.js ADDED
@@ -0,0 +1,188 @@
1
+ // Active SSE connection writers
2
+ const activeConnections = new Set();
3
+
4
+ // Regex to match ANSI escape sequences (control codes, color formatting, etc.)
5
+ const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
6
+
7
+ /**
8
+ * Sanitizes input strings by stripping out all ANSI escape sequences to prevent terminal XSS.
9
+ * @param {string} str
10
+ * @returns {string}
11
+ */
12
+ function sanitizeAnsi(str) {
13
+ if (typeof str !== 'string') return '';
14
+ return str.replace(ansiRegex, '');
15
+ }
16
+
17
+ export default {
18
+ /**
19
+ * Fetch handler for Cloudflare Worker.
20
+ * @param {Request} request
21
+ * @param {Object} env
22
+ * @param {Object} ctx
23
+ * @returns {Promise<Response>}
24
+ */
25
+ async fetch(request, env, ctx) {
26
+ const url = new URL(request.url);
27
+
28
+ // Helper for structured JSON responses
29
+ const jsonResponse = (data, status = 200) => {
30
+ return new Response(JSON.stringify(data), {
31
+ status,
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ 'Access-Control-Allow-Origin': '*',
35
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
36
+ 'Access-Control-Allow-Headers': '*',
37
+ },
38
+ });
39
+ };
40
+
41
+ // Handle OPTIONS request for CORS preflight
42
+ if (request.method === 'OPTIONS') {
43
+ return new Response(null, {
44
+ status: 204,
45
+ headers: {
46
+ 'Access-Control-Allow-Origin': '*',
47
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
48
+ 'Access-Control-Allow-Headers': '*',
49
+ },
50
+ });
51
+ }
52
+
53
+ // Routing for GET /listen
54
+ if (url.pathname === '/listen') {
55
+ if (request.method !== 'GET') {
56
+ return jsonResponse({ error: 'Method Not Allowed' }, 405);
57
+ }
58
+
59
+ const { readable, writable } = new TransformStream();
60
+ const writer = writable.getWriter();
61
+
62
+ // Add writer to active connections tracking
63
+ activeConnections.add(writer);
64
+
65
+ // Handle connection disconnect to prevent memory leaks
66
+ request.signal.addEventListener('abort', () => {
67
+ activeConnections.delete(writer);
68
+ try {
69
+ writer.close();
70
+ } catch (_) {}
71
+ });
72
+
73
+ // Keep-alive or immediate connection handshake
74
+ const encoder = new TextEncoder();
75
+ writer.write(encoder.encode(': ok\n\n')).catch(() => {});
76
+
77
+ return new Response(readable, {
78
+ headers: {
79
+ 'Content-Type': 'text/event-stream',
80
+ 'Cache-Control': 'no-cache',
81
+ 'Connection': 'keep-alive',
82
+ 'Access-Control-Allow-Origin': '*',
83
+ 'Access-Control-Allow-Headers': '*',
84
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
85
+ },
86
+ });
87
+ }
88
+
89
+ // Routing for POST /say
90
+ if (url.pathname === '/say') {
91
+ if (request.method !== 'POST') {
92
+ return jsonResponse({ error: 'Method Not Allowed' }, 405);
93
+ }
94
+
95
+ try {
96
+ const body = await request.json();
97
+ const { user, text } = body || {};
98
+
99
+ // Validation: parameters must exist, be strings, and not be empty
100
+ if (!user || typeof user !== 'string' || !user.trim()) {
101
+ return jsonResponse({ error: 'Missing or invalid "user" parameter' }, 400);
102
+ }
103
+
104
+ if (!text || typeof text !== 'string' || !text.trim()) {
105
+ return jsonResponse({ error: 'Missing or invalid "text" parameter' }, 400);
106
+ }
107
+
108
+ // Sanitize username and content of ANSI escape sequences to prevent terminal XSS
109
+ const sanitizedUser = sanitizeAnsi(user);
110
+ const sanitizedText = sanitizeAnsi(text);
111
+
112
+ // Ensure database binding exists
113
+ if (!env.DB) {
114
+ return jsonResponse({ error: 'Database binding "DB" is not configured' }, 500);
115
+ }
116
+
117
+ // Insert into D1 messages table safely using parameterized bindings
118
+ await env.DB.prepare(
119
+ 'INSERT INTO messages (username, content) VALUES (?, ?)'
120
+ )
121
+ .bind(sanitizedUser, sanitizedText)
122
+ .run();
123
+
124
+ // Broadcast the message payload to all active SSE subscribers
125
+ const encoder = new TextEncoder();
126
+ const payload = JSON.stringify({
127
+ username: sanitizedUser,
128
+ content: sanitizedText,
129
+ created_at: new Date().toISOString(),
130
+ });
131
+ const messageChunk = encoder.encode(`data: ${payload}\n\n`);
132
+
133
+ const broadcastPromises = Array.from(activeConnections).map(async (w) => {
134
+ try {
135
+ await w.write(messageChunk);
136
+ } catch (_) {
137
+ activeConnections.delete(w);
138
+ try {
139
+ w.close();
140
+ } catch (__) {}
141
+ }
142
+ });
143
+
144
+ // Wait for all active broadcasts
145
+ await Promise.all(broadcastPromises);
146
+
147
+ return jsonResponse({
148
+ success: true,
149
+ message: 'Message sent successfully',
150
+ data: {
151
+ username: sanitizedUser,
152
+ content: sanitizedText,
153
+ },
154
+ }, 201);
155
+
156
+ } catch (err) {
157
+ return jsonResponse({ error: `Bad Request: ${err.message}` }, 400);
158
+ }
159
+ }
160
+
161
+ // Default route
162
+ return jsonResponse({ error: 'Not Found' }, 404);
163
+ },
164
+
165
+ /**
166
+ * Cron Trigger handler for database cleanup.
167
+ * Runs at the top of every hour.
168
+ * Deletes messages older than 6 hours.
169
+ * @param {Object} event
170
+ * @param {Object} env
171
+ * @param {Object} ctx
172
+ */
173
+ async scheduled(event, env, ctx) {
174
+ if (!env.DB) {
175
+ console.error('[Cron Trigger] Database binding "DB" is not configured');
176
+ return;
177
+ }
178
+
179
+ try {
180
+ const result = await env.DB.prepare(
181
+ "DELETE FROM messages WHERE created_at < datetime('now', '-6 hours')"
182
+ ).run();
183
+ console.log(`[Cron Trigger] Cleanup complete. Deleted rows: ${result.meta.changes || 0}`);
184
+ } catch (err) {
185
+ console.error('[Cron Trigger] Failed to delete old messages:', err);
186
+ }
187
+ },
188
+ };