hacker-lobby 1.0.0 → 1.0.2
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 +14 -2
- package/package.json +2 -2
- package/src/ui.js +6 -0
- package/src/worker.js +48 -40
package/index.js
CHANGED
|
@@ -89,6 +89,9 @@ function initChat() {
|
|
|
89
89
|
addSystemMessage(`Stream disconnected: ${err.message}`);
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
// Post join message to the server
|
|
93
|
+
sendMessage(getAlias(), 'joined the chat').catch(() => {});
|
|
94
|
+
|
|
92
95
|
rl.on('line', (line) => {
|
|
93
96
|
// Disable newline muting once readline has finished processing the line
|
|
94
97
|
muteNewline = false;
|
|
@@ -119,6 +122,11 @@ function initChat() {
|
|
|
119
122
|
}
|
|
120
123
|
});
|
|
121
124
|
|
|
125
|
+
// Handle Ctrl+C gracefully
|
|
126
|
+
rl.on('SIGINT', () => {
|
|
127
|
+
cleanupAndExit();
|
|
128
|
+
});
|
|
129
|
+
|
|
122
130
|
// Initial prompt display
|
|
123
131
|
rl.prompt(true);
|
|
124
132
|
}
|
|
@@ -143,7 +151,7 @@ function drawLayout() {
|
|
|
143
151
|
|
|
144
152
|
// 2. Draw static divider line just above the input prompt
|
|
145
153
|
moveCursor(rows - 1, 1);
|
|
146
|
-
process.stdout.write(COLORS.GRAY + '
|
|
154
|
+
process.stdout.write(COLORS.GRAY + '-'.repeat(cols) + COLORS.RESET);
|
|
147
155
|
|
|
148
156
|
// 3. Set the scrolling region for messages
|
|
149
157
|
setScrollRegion(topMargin, bottomMargin);
|
|
@@ -207,10 +215,14 @@ function addSystemMessage(text) {
|
|
|
207
215
|
moveCursor(rows, col);
|
|
208
216
|
}
|
|
209
217
|
|
|
210
|
-
function cleanupAndExit() {
|
|
218
|
+
async function cleanupAndExit() {
|
|
211
219
|
if (abortController) {
|
|
212
220
|
abortController.abort();
|
|
213
221
|
}
|
|
222
|
+
try {
|
|
223
|
+
// Send leave notification to server before exiting
|
|
224
|
+
await sendMessage(getAlias(), 'left the chat');
|
|
225
|
+
} catch (_) {}
|
|
214
226
|
resetScrollRegion();
|
|
215
227
|
clearScreen();
|
|
216
228
|
console.log(formatSystem('Goodbye!'));
|
package/package.json
CHANGED
package/src/ui.js
CHANGED
|
@@ -54,6 +54,12 @@ ${COLORS.CYAN} ██╗ ██╗ █████╗ ██████╗█
|
|
|
54
54
|
* @returns {string}
|
|
55
55
|
*/
|
|
56
56
|
export function formatMessage(sender, text) {
|
|
57
|
+
if (text === 'joined the chat') {
|
|
58
|
+
return `${COLORS.GREEN}${COLORS.BOLD}[+]${COLORS.RESET} ${COLORS.CYAN}${COLORS.BOLD}${sender}${COLORS.RESET} ${COLORS.NEON_GREEN}joined the chat${COLORS.RESET}`;
|
|
59
|
+
}
|
|
60
|
+
if (text === 'left the chat') {
|
|
61
|
+
return `${COLORS.RED}${COLORS.BOLD}[-]${COLORS.RESET} ${COLORS.CYAN}${COLORS.BOLD}${sender}${COLORS.RESET} ${COLORS.RED}left the chat${COLORS.RESET}`;
|
|
62
|
+
}
|
|
57
63
|
return `${COLORS.CYAN}${COLORS.BOLD}${sender}${COLORS.RESET}: ${text}`;
|
|
58
64
|
}
|
|
59
65
|
|
package/src/worker.js
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// Active SSE connection writers
|
|
2
|
-
const activeConnections = new Set();
|
|
3
|
-
|
|
4
1
|
// Regex to match ANSI escape sequences (control codes, color formatting, etc.)
|
|
5
2
|
const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
|
6
3
|
|
|
@@ -14,6 +11,52 @@ function sanitizeAnsi(str) {
|
|
|
14
11
|
return str.replace(ansiRegex, '');
|
|
15
12
|
}
|
|
16
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Periodically polls the D1 database for new messages and writes them to the SSE stream.
|
|
16
|
+
* This ensures multi-isolate reliability since Cloudflare Workers are stateless.
|
|
17
|
+
* @param {WritableStreamDefaultWriter} writer
|
|
18
|
+
* @param {Object} env
|
|
19
|
+
* @param {Request} request
|
|
20
|
+
*/
|
|
21
|
+
async function pollAndStream(writer, env, request) {
|
|
22
|
+
const encoder = new TextEncoder();
|
|
23
|
+
try {
|
|
24
|
+
const initialResult = await env.DB.prepare('SELECT MAX(id) as maxId FROM messages').first();
|
|
25
|
+
let lastSeenId = initialResult?.maxId || 0;
|
|
26
|
+
|
|
27
|
+
// Send initial handshake
|
|
28
|
+
await writer.write(encoder.encode(': ok\n\n'));
|
|
29
|
+
|
|
30
|
+
while (!request.signal.aborted) {
|
|
31
|
+
const { results } = await env.DB.prepare(
|
|
32
|
+
'SELECT id, username, content, created_at FROM messages WHERE id > ? ORDER BY id ASC'
|
|
33
|
+
)
|
|
34
|
+
.bind(lastSeenId)
|
|
35
|
+
.all();
|
|
36
|
+
|
|
37
|
+
if (results && results.length > 0) {
|
|
38
|
+
for (const msg of results) {
|
|
39
|
+
const payload = JSON.stringify({
|
|
40
|
+
username: msg.username,
|
|
41
|
+
content: msg.content,
|
|
42
|
+
created_at: msg.created_at,
|
|
43
|
+
});
|
|
44
|
+
await writer.write(encoder.encode(`data: ${payload}\n\n`));
|
|
45
|
+
lastSeenId = msg.id;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error('SSE Stream error:', err);
|
|
53
|
+
} finally {
|
|
54
|
+
try {
|
|
55
|
+
await writer.close();
|
|
56
|
+
} catch (_) {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
17
60
|
export default {
|
|
18
61
|
/**
|
|
19
62
|
* Fetch handler for Cloudflare Worker.
|
|
@@ -59,20 +102,8 @@ export default {
|
|
|
59
102
|
const { readable, writable } = new TransformStream();
|
|
60
103
|
const writer = writable.getWriter();
|
|
61
104
|
|
|
62
|
-
//
|
|
63
|
-
|
|
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(() => {});
|
|
105
|
+
// Start the D1 polling loop in the background, keeping it alive with ctx.waitUntil
|
|
106
|
+
ctx.waitUntil(pollAndStream(writer, env, request));
|
|
76
107
|
|
|
77
108
|
return new Response(readable, {
|
|
78
109
|
headers: {
|
|
@@ -121,29 +152,6 @@ export default {
|
|
|
121
152
|
.bind(sanitizedUser, sanitizedText)
|
|
122
153
|
.run();
|
|
123
154
|
|
|
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
155
|
return jsonResponse({
|
|
148
156
|
success: true,
|
|
149
157
|
message: 'Message sent successfully',
|