@cephalization/math 0.2.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.
@@ -0,0 +1,271 @@
1
+ import { test, expect, describe, afterEach } from "bun:test";
2
+ import { startServer, DEFAULT_PORT, type WebSocketMessage } from "./server";
3
+ import { createOutputBuffer } from "./buffer";
4
+
5
+ /**
6
+ * Helper to receive a WebSocket message with timeout.
7
+ */
8
+ function receiveMessage(ws: WebSocket, timeoutMs = 1000): Promise<string> {
9
+ return new Promise((resolve, reject) => {
10
+ const handler = (event: MessageEvent) => {
11
+ ws.removeEventListener("message", handler);
12
+ resolve(event.data as string);
13
+ };
14
+ ws.addEventListener("message", handler);
15
+ setTimeout(() => {
16
+ ws.removeEventListener("message", handler);
17
+ reject(new Error("timeout"));
18
+ }, timeoutMs);
19
+ });
20
+ }
21
+
22
+ /**
23
+ * Helper to wait for WebSocket connection to open.
24
+ */
25
+ function waitForOpen(ws: WebSocket, timeoutMs = 1000): Promise<boolean> {
26
+ return new Promise((resolve) => {
27
+ ws.onopen = () => resolve(true);
28
+ ws.onerror = () => resolve(false);
29
+ setTimeout(() => resolve(false), timeoutMs);
30
+ });
31
+ }
32
+
33
+ describe("startServer", () => {
34
+ let server: ReturnType<typeof startServer> | null = null;
35
+
36
+ afterEach(() => {
37
+ if (server) {
38
+ server.stop();
39
+ server = null;
40
+ }
41
+ });
42
+
43
+ test("starts server on default port 8314", () => {
44
+ const buffer = createOutputBuffer();
45
+ server = startServer({ buffer });
46
+
47
+ expect(server.port).toBe(DEFAULT_PORT);
48
+ expect(server.port).toBe(8314);
49
+ });
50
+
51
+ test("starts server on custom port", () => {
52
+ const buffer = createOutputBuffer();
53
+ server = startServer({ buffer, port: 9999 });
54
+
55
+ expect(server.port).toBe(9999);
56
+ });
57
+
58
+ test("serves HTML at /", async () => {
59
+ const buffer = createOutputBuffer();
60
+ server = startServer({ buffer, port: 8315 });
61
+
62
+ const response = await fetch("http://localhost:8315/");
63
+
64
+ expect(response.status).toBe(200);
65
+ // Bun's HTML imports add charset to content-type
66
+ expect(response.headers.get("content-type")).toContain("text/html");
67
+
68
+ const html = await response.text();
69
+ expect(html).toContain("<!DOCTYPE html>");
70
+ expect(html).toContain("Math Agent UI");
71
+ });
72
+
73
+ test("returns 404 for unknown routes", async () => {
74
+ const buffer = createOutputBuffer();
75
+ server = startServer({ buffer, port: 8316 });
76
+
77
+ const response = await fetch("http://localhost:8316/unknown");
78
+
79
+ expect(response.status).toBe(404);
80
+ });
81
+
82
+ test("accepts WebSocket connection at /ws", async () => {
83
+ const buffer = createOutputBuffer();
84
+ server = startServer({ buffer, port: 8317 });
85
+
86
+ const ws = new WebSocket("ws://localhost:8317/ws");
87
+
88
+ const connected = await waitForOpen(ws);
89
+ expect(connected).toBe(true);
90
+
91
+ // Should receive a connected message
92
+ const message = await receiveMessage(ws);
93
+ const parsed = JSON.parse(message);
94
+ expect(parsed.type).toBe("connected");
95
+ expect(parsed.id).toBeDefined();
96
+
97
+ ws.close();
98
+ });
99
+ });
100
+
101
+ describe("WebSocket streaming", () => {
102
+ let server: ReturnType<typeof startServer> | null = null;
103
+
104
+ afterEach(() => {
105
+ if (server) {
106
+ server.stop();
107
+ server = null;
108
+ }
109
+ });
110
+
111
+ test("sends full history on connect", async () => {
112
+ const buffer = createOutputBuffer();
113
+ // Add some history before connecting
114
+ buffer.appendLog("info", "test log 1");
115
+ buffer.appendLog("error", "test log 2");
116
+ buffer.appendOutput("agent output 1");
117
+
118
+ server = startServer({ buffer, port: 8318 });
119
+ const ws = new WebSocket("ws://localhost:8318/ws");
120
+ await waitForOpen(ws);
121
+
122
+ // First message is connected
123
+ const connectedMsg = await receiveMessage(ws);
124
+ expect(JSON.parse(connectedMsg).type).toBe("connected");
125
+
126
+ // Second message is history
127
+ const historyMsg = await receiveMessage(ws);
128
+ const history = JSON.parse(historyMsg) as WebSocketMessage;
129
+
130
+ expect(history.type).toBe("history");
131
+ if (history.type === "history") {
132
+ expect(history.logs).toHaveLength(2);
133
+ expect(history.logs[0]!.message).toBe("test log 1");
134
+ expect(history.logs[0]!.category).toBe("info");
135
+ expect(history.logs[1]!.message).toBe("test log 2");
136
+ expect(history.logs[1]!.category).toBe("error");
137
+ expect(history.output).toHaveLength(1);
138
+ expect(history.output[0]!.text).toBe("agent output 1");
139
+ }
140
+
141
+ ws.close();
142
+ });
143
+
144
+ test("broadcasts new log entries to connected clients", async () => {
145
+ const buffer = createOutputBuffer();
146
+ server = startServer({ buffer, port: 8319 });
147
+
148
+ const ws = new WebSocket("ws://localhost:8319/ws");
149
+ await waitForOpen(ws);
150
+
151
+ // Drain connected and history messages
152
+ await receiveMessage(ws); // connected
153
+ await receiveMessage(ws); // history
154
+
155
+ // Add a new log entry after connection
156
+ buffer.appendLog("success", "new log entry");
157
+
158
+ // Should receive the new log entry
159
+ const logMsg = await receiveMessage(ws);
160
+ const parsed = JSON.parse(logMsg) as WebSocketMessage;
161
+
162
+ expect(parsed.type).toBe("log");
163
+ if (parsed.type === "log") {
164
+ expect(parsed.entry.message).toBe("new log entry");
165
+ expect(parsed.entry.category).toBe("success");
166
+ }
167
+
168
+ ws.close();
169
+ });
170
+
171
+ test("broadcasts new agent output to connected clients", async () => {
172
+ const buffer = createOutputBuffer();
173
+ server = startServer({ buffer, port: 8320 });
174
+
175
+ const ws = new WebSocket("ws://localhost:8320/ws");
176
+ await waitForOpen(ws);
177
+
178
+ // Drain connected and history messages
179
+ await receiveMessage(ws); // connected
180
+ await receiveMessage(ws); // history
181
+
182
+ // Add new agent output after connection
183
+ buffer.appendOutput("new agent output");
184
+
185
+ // Should receive the new output entry
186
+ const outputMsg = await receiveMessage(ws);
187
+ const parsed = JSON.parse(outputMsg) as WebSocketMessage;
188
+
189
+ expect(parsed.type).toBe("output");
190
+ if (parsed.type === "output") {
191
+ expect(parsed.entry.text).toBe("new agent output");
192
+ }
193
+
194
+ ws.close();
195
+ });
196
+
197
+ test("broadcasts to multiple connected clients", async () => {
198
+ const buffer = createOutputBuffer();
199
+ server = startServer({ buffer, port: 8321 });
200
+
201
+ // Collect all messages received by each client
202
+ const messages1: string[] = [];
203
+ const messages2: string[] = [];
204
+
205
+ const ws1 = new WebSocket("ws://localhost:8321/ws");
206
+ const ws2 = new WebSocket("ws://localhost:8321/ws");
207
+
208
+ ws1.onmessage = (event) => messages1.push(event.data as string);
209
+ ws2.onmessage = (event) => messages2.push(event.data as string);
210
+
211
+ await Promise.all([waitForOpen(ws1), waitForOpen(ws2)]);
212
+
213
+ // Wait for initial messages (connected + history)
214
+ await new Promise((resolve) => setTimeout(resolve, 100));
215
+
216
+ // Add new log
217
+ buffer.appendLog("warning", "broadcast test");
218
+
219
+ // Wait for broadcast to arrive
220
+ await new Promise((resolve) => setTimeout(resolve, 100));
221
+
222
+ // Both clients should have received the log message
223
+ const logMessages1 = messages1
224
+ .map((m) => JSON.parse(m) as WebSocketMessage)
225
+ .filter((m) => m.type === "log");
226
+ const logMessages2 = messages2
227
+ .map((m) => JSON.parse(m) as WebSocketMessage)
228
+ .filter((m) => m.type === "log");
229
+
230
+ expect(logMessages1).toHaveLength(1);
231
+ expect(logMessages2).toHaveLength(1);
232
+ if (logMessages1[0]?.type === "log" && logMessages2[0]?.type === "log") {
233
+ expect(logMessages1[0].entry.message).toBe("broadcast test");
234
+ expect(logMessages2[0].entry.message).toBe("broadcast test");
235
+ }
236
+
237
+ ws1.close();
238
+ ws2.close();
239
+ });
240
+
241
+ test("unsubscribes from buffer on disconnect", async () => {
242
+ const buffer = createOutputBuffer();
243
+ server = startServer({ buffer, port: 8322 });
244
+
245
+ const ws = new WebSocket("ws://localhost:8322/ws");
246
+ await waitForOpen(ws);
247
+
248
+ // Drain initial messages
249
+ await receiveMessage(ws); // connected
250
+ await receiveMessage(ws); // history
251
+
252
+ // Close the connection
253
+ ws.close();
254
+
255
+ // Wait a bit for the close handler to run
256
+ await new Promise((resolve) => setTimeout(resolve, 50));
257
+
258
+ // This should not throw - the subscriber was cleaned up
259
+ // If unsubscribe didn't work, the subscriber would try to send to a closed socket
260
+ buffer.appendLog("info", "after disconnect");
261
+
262
+ // Test passes if no error is thrown
263
+ expect(true).toBe(true);
264
+ });
265
+ });
266
+
267
+ describe("DEFAULT_PORT", () => {
268
+ test("is 8314", () => {
269
+ expect(DEFAULT_PORT).toBe(8314);
270
+ });
271
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Web server for the UI that streams loop logs and agent output.
3
+ * Uses Bun.serve() with HTTP routes and WebSocket support.
4
+ */
5
+
6
+ import type { ServerWebSocket } from "bun";
7
+ import type { OutputBuffer, BufferLogEntry, BufferAgentOutput } from "./buffer";
8
+
9
+ // Import HTML file using Bun's HTML imports feature
10
+ // This allows Bun to automatically bundle the React app and handle HMR
11
+ import indexHtml from "./index.html";
12
+
13
+ /**
14
+ * Options for starting the UI server.
15
+ */
16
+ export interface ServerOptions {
17
+ port?: number;
18
+ buffer: OutputBuffer;
19
+ }
20
+
21
+ /**
22
+ * Default port for the UI server.
23
+ */
24
+ export const DEFAULT_PORT = 8314;
25
+
26
+ /**
27
+ * Data attached to each WebSocket connection.
28
+ */
29
+ interface WebSocketData {
30
+ id: string;
31
+ unsubscribeLogs: (() => void) | null;
32
+ unsubscribeOutput: (() => void) | null;
33
+ }
34
+
35
+ /**
36
+ * WebSocket message types for client communication.
37
+ */
38
+ export type WebSocketMessage =
39
+ | { type: "connected"; id: string }
40
+ | { type: "history"; logs: BufferLogEntry[]; output: BufferAgentOutput[] }
41
+ | { type: "log"; entry: BufferLogEntry }
42
+ | { type: "output"; entry: BufferAgentOutput };
43
+
44
+ /**
45
+ * Start the UI web server.
46
+ * Returns the server instance for later shutdown if needed.
47
+ */
48
+ export function startServer(options: ServerOptions) {
49
+ const { port = DEFAULT_PORT, buffer } = options;
50
+
51
+ const server = Bun.serve<WebSocketData>({
52
+ port,
53
+
54
+ routes: {
55
+ // Serve the React app using Bun's HTML imports
56
+ // Bun automatically handles bundling and HMR in development
57
+ "/": indexHtml,
58
+ },
59
+
60
+ fetch(req, server) {
61
+ const url = new URL(req.url);
62
+
63
+ // Handle WebSocket upgrade at /ws
64
+ if (url.pathname === "/ws") {
65
+ const id = crypto.randomUUID();
66
+ const success = server.upgrade(req, {
67
+ data: { id, unsubscribeLogs: null, unsubscribeOutput: null },
68
+ });
69
+ if (success) {
70
+ return undefined;
71
+ }
72
+ return new Response("WebSocket upgrade failed", { status: 400 });
73
+ }
74
+
75
+ // 404 for unmatched routes
76
+ return new Response("Not Found", { status: 404 });
77
+ },
78
+
79
+ websocket: {
80
+ open(ws: ServerWebSocket<WebSocketData>) {
81
+ // Send connected message
82
+ ws.send(JSON.stringify({ type: "connected", id: ws.data.id }));
83
+
84
+ // Send full history
85
+ const historyMessage: WebSocketMessage = {
86
+ type: "history",
87
+ logs: buffer.getLogs(),
88
+ output: buffer.getOutput(),
89
+ };
90
+ ws.send(JSON.stringify(historyMessage));
91
+
92
+ // Subscribe to new log entries and broadcast to this client
93
+ ws.data.unsubscribeLogs = buffer.subscribeLogs((entry) => {
94
+ const message: WebSocketMessage = { type: "log", entry };
95
+ ws.send(JSON.stringify(message));
96
+ });
97
+
98
+ // Subscribe to new agent output and broadcast to this client
99
+ ws.data.unsubscribeOutput = buffer.subscribeOutput((entry) => {
100
+ const message: WebSocketMessage = { type: "output", entry };
101
+ ws.send(JSON.stringify(message));
102
+ });
103
+ },
104
+
105
+ message(ws: ServerWebSocket<WebSocketData>, message: string | Buffer) {
106
+ // No client-to-server messages needed yet
107
+ },
108
+
109
+ close(ws: ServerWebSocket<WebSocketData>) {
110
+ // Unsubscribe from buffer updates
111
+ if (ws.data.unsubscribeLogs) {
112
+ ws.data.unsubscribeLogs();
113
+ ws.data.unsubscribeLogs = null;
114
+ }
115
+ if (ws.data.unsubscribeOutput) {
116
+ ws.data.unsubscribeOutput();
117
+ ws.data.unsubscribeOutput = null;
118
+ }
119
+ },
120
+ },
121
+ });
122
+
123
+ return server;
124
+ }