Skip to content

Commit dccad78

Browse files
committed
feat: implement chat functionality with message validation and streaming
1 parent 280b13c commit dccad78

File tree

9 files changed

+244
-18
lines changed

9 files changed

+244
-18
lines changed

package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"@astrojs/cloudflare": "^12.4.0",
1414
"@astrojs/react": "^4.2.3",
1515
"@tailwindcss/vite": "^4.1.2",
16+
"@types/node": "^22.15.3",
1617
"@types/react": "^19.1.0",
1718
"@types/react-dom": "^19.1.1",
1819
"astro": "^5.6.0",

src/components/Header.astro

Lines changed: 89 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
import { authors } from "../ts/vars";
3+
import ChatController from "../controllers/chat";
34
45
// math equation to generate the accurate number of the day
56
const x = new Date().getDate();
@@ -20,6 +21,8 @@ if (Math.random() < 0.1) {
2021
2122
let accuracyStyle = `color: hsl(${accuracyPercentage}, 100%, 50%); font-weight: bold;`;
2223
const author = authors[Math.floor(Math.random() * authors.length)];
24+
25+
const messages = ChatController.getInstance().getMessages();
2326
---
2427

2528
<script>
@@ -72,6 +75,56 @@ const author = authors[Math.floor(Math.random() * authors.length)];
7275
}
7376
</script>
7477

78+
<script is:inline>
79+
document.addEventListener("DOMContentLoaded", () => {
80+
const form = document.getElementById("chat");
81+
const input = document.getElementById("message");
82+
let lastSubmittedMessage = "";
83+
form.addEventListener("submit", (e) => {
84+
e.preventDefault();
85+
86+
// Save the message BEFORE clearing the input
87+
lastSubmittedMessage = input.value;
88+
89+
fetch("/api/chat", {
90+
method: "POST",
91+
body: JSON.stringify({ message: input.value }),
92+
headers: {
93+
"Content-Type": "application/json",
94+
},
95+
});
96+
input.value = "";
97+
input.focus();
98+
});
99+
100+
const eventSource = new EventSource("/api/stream");
101+
eventSource.onmessage = (event) => {
102+
const messages = document.getElementById("messages");
103+
104+
// Create the new message element first
105+
const li = document.createElement("li");
106+
li.className = "text-sm text-white list-none text-left";
107+
108+
// Compare with the saved message
109+
if (lastSubmittedMessage === JSON.parse(event.data)) {
110+
li.innerText = "YOU: " + JSON.parse(event.data);
111+
// Clear lastSubmittedMessage after it's been matched
112+
lastSubmittedMessage = "";
113+
} else {
114+
li.innerText = JSON.parse(event.data);
115+
}
116+
117+
// Add the new message
118+
messages.appendChild(li);
119+
120+
// Then check if we need to remove old messages
121+
while (messages.children.length > 5) {
122+
messages.removeChild(messages.firstChild);
123+
}
124+
};
125+
});
126+
</script>
127+
75128
<header class="text-center text-white py-8">
76129
<h1 class="text-4xl font-bold text-grain-text hover:animate-vibrate2x">
77130
<a href="/fizzbuzz" class="hover:underline">accuratelinuxgraphs.com</a>
@@ -141,21 +194,41 @@ const author = authors[Math.floor(Math.random() * authors.length)];
141194
title="Ad"
142195
/></a
143196
>
144-
</div>
145-
<audio loop id="accuRotateAudio" class="hidden">
146-
<source
147-
src="/assets/audio/extremebinarydrumandbass.mp3"
148-
type="audio/mp3"
149-
id="accuRotateAudio2"
150-
/>
151-
Your browser does not support the audio element. Seek help.
152-
</audio>
153-
<p class="invisible" id="credits">
154-
AccuRotate Audio Credit: <a
155-
href="https://opengameart.org/content/extreme-binary-dnb"
156-
>Gobusto - Extreme Binary DnB</a
157-
>
158-
</p>
159197

160-
<hr class="animate-ping" />
198+
<div class="bg-grain-panel rounded-lg p-4 mt-4">
199+
<h2 class="text-xl font-bold mb-2">Chat</h2>
200+
<ul id="messages" class="list-disc pl-5 font-bold bg-grain-bg">
201+
{
202+
messages.map((message) => (
203+
<li class="text-sm text-white list-none text-left">{message}</li>
204+
))
205+
}
206+
</ul>
207+
<form id="chat">
208+
<input
209+
type="text"
210+
id="message"
211+
class="border border-gray-300 rounded px-2 py-1 w-full mt-2"
212+
placeholder="Type your message here..."
213+
/>
214+
<input type="submit" hidden />
215+
</form>
216+
</div>
217+
<audio loop id="accuRotateAudio" class="hidden">
218+
<source
219+
src="/assets/audio/extremebinarydrumandbass.mp3"
220+
type="audio/mp3"
221+
id="accuRotateAudio2"
222+
/>
223+
Your browser does not support the audio element. Seek help.
224+
</audio>
225+
<p class="invisible" id="credits">
226+
AccuRotate Audio Credit: <a
227+
href="https://opengameart.org/content/extreme-binary-dnb"
228+
>Gobusto - Extreme Binary DnB</a
229+
>
230+
</p>
231+
232+
<hr class="animate-ping" />
233+
</div>
161234
</header>

src/controllers/chat.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import EventEmitter from "events";
2+
import { isValidMessage } from "../ts/chat";
3+
4+
export default class ChatController {
5+
private static instance: ChatController;
6+
private constructor() {}
7+
8+
static getInstance(): ChatController {
9+
if (!ChatController.instance) {
10+
ChatController.instance = new ChatController();
11+
}
12+
return ChatController.instance;
13+
}
14+
private messages: string[] = [];
15+
private closedCallbacks: Set<Function> = new Set(); // Track which callbacks are from closed connections
16+
17+
public getMessages(): string[] {
18+
return this.messages;
19+
}
20+
private emitter = new EventEmitter();
21+
22+
public subscribe(callback: (message: string) => void): void {
23+
this.emitter.on("message", callback);
24+
}
25+
26+
public unsubscribe(callback: (message: string) => void): void {
27+
// Mark this callback as coming from a closed connection
28+
this.closedCallbacks.add(callback);
29+
this.emitter.off("message", callback);
30+
}
31+
32+
public addMessage(message: string): void {
33+
if (!isValidMessage(message)) {
34+
message =
35+
"SYS: Messages are limited to 3 words and only the words 'accurate', 'linux', and 'graphs' are allowed. Also a few more, have fun finding them!";
36+
}
37+
this.messages.push(message);
38+
39+
// Get all listeners BEFORE emitting to avoid race conditions
40+
const listeners = this.emitter.listeners("message");
41+
42+
// Only emit to listeners that aren't closed
43+
for (const listener of listeners) {
44+
if (!this.closedCallbacks.has(listener)) {
45+
try {
46+
listener(message);
47+
} catch (err) {
48+
console.error("Error in message listener:", err);
49+
}
50+
}
51+
}
52+
53+
// remove messages older than 5 so it doesn't get too long
54+
if (this.messages.length > 5) {
55+
this.messages.shift();
56+
}
57+
}
58+
}

src/pages/api/chat.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { APIRoute } from "astro";
2+
import ChatController from "../../controllers/chat";
3+
4+
export const POST: APIRoute = async ({ request }) => {
5+
const { message } = await request.json();
6+
ChatController.getInstance().addMessage(message);
7+
return new Response(null, { status: 204 });
8+
};

src/pages/api/stream.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { APIRoute } from "astro";
2+
import ChatController from "../../controllers/chat";
3+
4+
export const GET: APIRoute = async ({ request }) => {
5+
const body = new ReadableStream({
6+
start(controller) {
7+
const encoder = new TextEncoder();
8+
let isClosed = false;
9+
10+
const sendEvent = (data: any) => {
11+
// Double protection: check flag and try-catch the operation
12+
if (isClosed) return;
13+
14+
try {
15+
const message = `data: ${JSON.stringify(data)}\n\n`;
16+
controller.enqueue(encoder.encode(message));
17+
} catch (error) {
18+
// If we somehow reach here with a closed controller, mark as closed
19+
console.error("Error sending event:", error);
20+
isClosed = true;
21+
}
22+
};
23+
24+
// Subscribe to new messages
25+
ChatController.getInstance().subscribe(sendEvent);
26+
27+
request.signal.addEventListener("abort", () => {
28+
// Mark as closed first
29+
isClosed = true;
30+
// Unsubscribe from new messages - doing this synchronously
31+
ChatController.getInstance().unsubscribe(sendEvent);
32+
33+
try {
34+
controller.close();
35+
} catch (error) {
36+
console.error("Error closing controller:", error);
37+
}
38+
});
39+
},
40+
});
41+
42+
return new Response(body, {
43+
headers: {
44+
"Content-Type": "text/event-stream",
45+
"Cache-Control": "no-cache",
46+
Connection: "keep-alive",
47+
},
48+
});
49+
};

src/pages/files/img/[img].astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,4 @@ export function getStaticPaths() {
3737
}
3838
3939
return Astro.redirect(`/assets/graphs/${Astro.params.img}`);
40-
40+
---

src/pages/fizzbuzz.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const fizzbuzz = (n: number) => {
1919
};
2020
---
2121

22-
<script>
22+
<script lang="js">
2323
// on click of anywhere on the site, spawn 10 oscillators playing droning randomized notes
2424
// and play them forever
2525
document.addEventListener("click", () => {

src/ts/chat.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// needs to be 3 words maximum, only words allowed are "accurate", "linux", "graphs"
2+
export function isValidMessage(message: string): boolean {
3+
const words = message.split(" ");
4+
if (words.length > 3) {
5+
return false;
6+
}
7+
const validWords = [
8+
"accurate",
9+
"linux",
10+
"graphs",
11+
"accuracy",
12+
"accuratelinuxgraphs",
13+
"hi",
14+
];
15+
for (const word of words) {
16+
if (!validWords.includes(word)) {
17+
return false;
18+
}
19+
}
20+
return true;
21+
}

0 commit comments

Comments
 (0)