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 +221 -0
- package/package.json +20 -0
- package/src/api.js +76 -0
- package/src/config.js +19 -0
- package/src/ui.js +122 -0
- package/src/worker.js +188 -0
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
|
+
};
|